Example examples/terrain
Terrain

This is the outdoor scene a strategy or survival game draws. A
gfx.Terrain holds the heightfield, splits it into chunks, keeps four
resolutions of each and draws every chunk at the one its distance
deserves, shading the ground through a splat map that blends sand,
grass, rock and snow. On top of it: a lake, a hundred and twenty pines
that are models up close and baked impostors further out, four hundred
billboard trees, eighty rocks at whichever level of detail their
distance calls for, four campfires as flickering point lights, a
watchtower whose searchlight is a spot light, distance and height fog,
labels standing in the world, a second camera's view volume drawn as
lines, and terrain the player digs into with a click, which rebuilds
the chunks it touched while the scene is running.
It uses the 3D half of gfx broadly: Terrain with
SetSplat, Height, Normal, Raycast, Heights and Update,
BakeImpostor and DrawModelImpostor, LoadModel from a document
built in memory, LOD and DrawLODAt, DrawBillboard,
AddPointLight and AddSpotLight, Sky and Fog on the light,
DrawText3D, DrawWireFrustum, Frustum().ContainsSphere for culling
by hand, PostSettings, and Stats for the counts in the corner. The
guide for this material is
3D graphics.
Run it with:
go run ./examples/terrain -seconds 3 -shot out.png
The flags are -seconds N and -shot file.png. Right-drag orbits the
camera, the scroll wheel zooms, a left click digs a crater where the ray
meets the ground, and Escape quits. With no drag the camera turns slowly
on its own.
Constants and the game type
The heightfield is 129 by 129 samples a world unit apart, so the terrain
is 128 units square and splits into sixteen chunks of 32 samples.
Terrain needs the sample count minus one to be a whole number of
chunks, which is why the odd numbers are there.
The game keeps the terrain, the four tiling ground textures the splat map chooses between, the pine model and its impostor, and the scattered placements. It no longer keeps the heights: the terrain owns them.
const (
cols, rows = 129, 129 // height samples across and deep
cell = 1.0 // world units per sample
chunk = 32 // samples across one terrain chunk
splatSize = 128 // the splat map's pixels a side
)
type game struct {
seconds float64
shot string
shotDone bool
font *gfx.Font
terrain *gfx.Terrain
layers [4]*gfx.Texture
water *gfx.Mesh
tower *gfx.Mesh
roof *gfx.Mesh
ember *gfx.Mesh
rocks *gfx.LOD
rockAt []gfx.Transform
pine *gfx.Model
pineFar *gfx.Impostor
pineAt []gfx.Transform
tree *gfx.Texture
trees []lin.Vec3
fires []lin.Vec3
towerAt lin.Vec3
yaw, pitch, dist float32
dug bool
skipped int
}
The shape of the ground
Three sine waves at different frequencies make rolling hills, a
gaussian well digs the lake basin, and another gaussian ridge runs
along the north edge. heights samples it into the flat grid
NewTerrain takes, row by row.
// height is the terrain's shape: rolling hills with a lake basin in the
// middle and a ridge to the north.
func height(x, z float32) float32 {
h := 3*float32(math.Sin(float64(x)*0.08)*math.Cos(float64(z)*0.06)) +
1.5*float32(math.Sin(float64(x)*0.21+1.3)*math.Sin(float64(z)*0.17)) +
0.4*float32(math.Sin(float64(x)*0.9)*math.Cos(float64(z)*0.7))
r := float32(math.Hypot(float64(x)+8, float64(z)-6))
h -= 4 * float32(math.Exp(-float64(r*r)/300))
h += 3 * float32(math.Exp(-float64((z+30)*(z+30))/200))
return h + 1
}
// heights fills the sample grid from height, centred on the origin.
func heights() []float32 {
h := make([]float32, cols*rows)
for z := range rows {
for x := range cols {
h[z*cols+x] = height(float32(x-cols/2)*cell, float32(z-rows/2)*cell)
}
}
return h
}
The splat map
The terrain shader blends four tiling layers by the four channels of a
splat map stretched over the whole field. This one is painted from the
terrain's own Height and Normal, so the ground textures follow the
shape rather than a second copy of it: sand where the land is low
enough to be beach, snow on the peaks, rock wherever the surface leans
more than about ten degrees, and grass with whatever weight the other
three leave over. Because the weights are normalised in the shader,
they need not sum to one; they only have to be in proportion.
// splatImage weights the four ground layers by height and slope: sand by
// the water, grass on gentle land, rock on steep faces and snow on the
// peaks. Each pixel's channels are the weights of layers one to four, so
// the terrain shader blends them where they meet.
func (g *game) splatImage() *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, splatSize, splatSize))
span := float32(cols-1) * cell
for py := range splatSize {
for px := range splatSize {
x := (float32(px)/(splatSize-1) - 0.5) * span
z := (float32(py)/(splatSize-1) - 0.5) * span
h := g.terrain.Height(x, z)
slope := 1 - g.terrain.Normal(x, z).Y
sand := clamp01(1.5 - h)
snow := clamp01((h - 4.5) * 0.5)
rock := clamp01((slope - 0.18) * 6)
grass := clamp01(1 - sand - snow - rock)
img.SetRGBA(px, py, color.RGBA{scale8(sand), scale8(grass), scale8(rock), scale8(snow)})
}
}
return img
}
func clamp01(v float32) float32 { return lin.Clamp(v, 0, 1) }
func scale8(v float32) uint8 { return uint8(clamp01(v)*255 + 0.5) }
The layers themselves are generated rather than loaded: a flat colour
with a little per-pixel grain, which is enough for them to read as
different materials at the scale the ground is seen from. Each one is
uploaded with Repeat, since the shader tiles it every few world units.
// groundLayer makes one tiling ground texture: a flat colour with a
// little per-pixel grain, so the layers read as different materials
// without any art.
func groundLayer(base color.RGBA, grain int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, 32, 32))
r := rand.New(rand.NewSource(int64(base.R) + 31*int64(base.G)))
for y := range 32 {
for x := range 32 {
d := r.Intn(2*grain+1) - grain
img.SetRGBA(x, y, color.RGBA{shade(base.R, d), shade(base.G, d), shade(base.B, d), 255})
}
}
return img
}
func shade(v uint8, d int) uint8 { return uint8(min(max(int(v)+d, 0), 255)) }
The billboard tree
The four hundred distant trees are one cutout texture drawn as camera-facing quads: a green canopy widening down the image over a brown trunk, with everything else left at zero alpha.
// treeImage draws a cutout tree: a green canopy over a brown trunk.
func treeImage() *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, 64, 96))
for y := 0; y < 96; y++ {
for x := 0; x < 64; x++ {
var c color.RGBA
switch {
case y >= 62 && x >= 28 && x < 36:
c = color.RGBA{92, 64, 40, 255}
case y < 70 && math.Abs(float64(x)-32) < float64(y)*0.42+2:
shade := uint8(90 + (x%7)*8)
c = color.RGBA{30, shade, 40, 255}
}
img.SetRGBA(x, y, c)
}
}
return img
}
One light, used twice
The scene's light lives in a function because two things need it: the
frame, and the impostor bake. An impostor is a picture of the model, so
it only matches the models beside it if it was lit the same way.
BakeImpostor ignores the parts of the light that belong to the frame
rather than to the model, so the same value can be handed to both.
The sky is an Atmosphere rather than two painted colours, which is why
the light takes the camera's altitude: the scattering model needs to know
where in the air the viewer stands, and it tints the far hills as well as
the sky.
// sunlight is the scene's directional light, sky and fog, for a camera at
// that altitude. The sky is scattered rather than painted: an Atmosphere
// replaces the Zenith and Horizon colours, so the low sun leaves the
// horizon orange and the sky overhead blue, and the same model tints the
// far hills with the air in front of them. Height is how deep the air is
// in this world's units. Fog is left to the valley: the air handles
// distance.
//
// The impostor bake takes the same light, so a pine baked into the atlas
// is lit the way the pine models beside it are; BakeImpostor drops the
// background, the shadows and the fog, which the frame applies again.
func sunlight(altitude float32) gfx.Light {
mist := gfx.Color{R: 0.78, G: 0.75, B: 0.68, A: 1}
return gfx.Light{
Direction: lin.V3(-0.6, -0.3, -0.4),
Color: gfx.Color{R: 1, G: 0.95, B: 0.85, A: 1},
Sky: gfx.Sky{Ground: gfx.Color{R: 0.3, G: 0.32, B: 0.25, A: 1}, Atmosphere: gfx.Atmosphere{Height: 3000, Altitude: max(altitude, 0)}},
Shadows: true,
ShadowDistance: 90,
Background: true,
Fog: gfx.Fog{Color: mist, Start: 45, End: 200, Height: 0.8, HeightFalloff: 0.4},
}
}
A model built in memory
An impostor is baked from a gfx.Model, and a model comes from a glTF
document. The document does not have to come from a file: gltf.Document
is plain Go slices, so the pine is assembled here from a cylinder and
three cones, each transformed into place and appended as its own
primitive with its own material. gltf.Load produces exactly this shape
from a .glb.
// pineDocument builds a pine as a glTF document in memory: a brown trunk
// and three green skirts of foliage, each a cone. A file loads the same
// way through gltf.Load; this keeps the example to one program.
func pineDocument() *gltf.Document {
doc := &gltf.Document{
Materials: []gltf.Material{
{Name: "bark", BaseColor: [4]float32{0.22, 0.14, 0.08, 1}, Roughness: 1, Image: -1, MetalRoughImage: -1, NormalImage: -1, EmissiveImage: -1, OcclusionImage: -1, TransmissionImage: -1, ThicknessImage: -1, UVScale: [2]float32{1, 1}},
{Name: "needles", BaseColor: [4]float32{0.07, 0.24, 0.09, 1}, Roughness: 1, Image: -1, MetalRoughImage: -1, NormalImage: -1, EmissiveImage: -1, OcclusionImage: -1, TransmissionImage: -1, ThicknessImage: -1, UVScale: [2]float32{1, 1}},
},
}
part := func(verts []gfx.Vertex, idx []uint32, material int, m lin.Mat4) gltf.Primitive {
p := gltf.Primitive{Indices: idx, Material: material}
nm := m.NormalMatrix()
for _, v := range verts {
p.Positions = append(p.Positions, m.MulPoint(v.Pos))
p.Normals = append(p.Normals, nm.MulVec(v.Normal).Norm())
p.UVs = append(p.UVs, v.UV)
}
return p
}
cv, ci := gfx.CylinderMesh(8)
kv, ki := gfx.ConeMesh(10)
mesh := gltf.Mesh{Name: "pine", Primitives: []gltf.Primitive{
part(cv, ci, 0, lin.Translate(lin.V3(0, 1.1, 0)).Mul(lin.Scale(lin.V3(0.16, 1.1, 0.16)))),
}}
for i, y := range []float32{1.6, 2.6, 3.5} {
s := 1.3 - float32(i)*0.35
mesh.Primitives = append(mesh.Primitives, part(kv, ki, 1, lin.Translate(lin.V3(0, y, 0)).Mul(lin.Scale(lin.V3(s, 1.1, s)))))
}
doc.Meshes = []gltf.Mesh{mesh}
doc.Nodes = []gltf.Node{{Name: "pine", Parent: -1, Rotation: lin.QuatIdentity(), Scale: lin.V3(1, 1, 1), Mesh: 0, Skin: -1}}
doc.Instances = []gltf.Instance{{Name: "pine", Mesh: 0, Node: 0, Skin: -1, World: lin.Identity()}}
return doc
}
Building the world
Init makes the four ground textures, then the terrain. NewTerrain
copies the heights, builds every chunk at every level and makes the
splat texture and the shader; the splat is filled in afterwards, because
its weights are read out of the terrain that does not exist yet when the
options are written.
The rocks are the usual LOD: a fine sphere near, a faceted one far and
nothing at all beyond seventy units. The pines get an impostor instead,
baked from twelve directions at 96 pixels each, under the same light the
frame uses, and swapped in beyond thirty units.
BakeImpostor runs a frame of its own to render the views and reads
them back, which is why it belongs in Init rather than in Draw.
func (g *game) Init(ctx *engine.Context) error {
var err error
if g.font, err = ctx.Gfx.NewFont(goregular.TTF, 28, gfx.FontOptions{}); err != nil {
return err
}
for i, c := range [4]color.RGBA{{194, 178, 128, 255}, {86, 125, 50, 255}, {110, 105, 100, 255}, {235, 240, 245, 255}} {
if g.layers[i], err = ctx.Gfx.NewTexture(groundLayer(c, 14), gfx.TextureOptions{Repeat: true}); err != nil {
return err
}
}
// The terrain owns the heightfield, the chunk meshes at four
// resolutions each, the splat texture and the shader that blends the
// layers. It is built once with a flat splat, then given the real one
// through its shader, because the weights are computed from the
// terrain's own height and slope queries.
if g.terrain, err = ctx.Gfx.NewTerrain(gfx.TerrainOptions{
Heights: heights(), Cols: cols, Rows: rows, Cell: cell, ChunkSize: chunk,
Levels: 4, LODDistance: 45,
Layers: g.layers, LayerScale: [4]float32{6, 5, 4, 7},
LayerRoughness: [4]float32{0.95, 0.9, 0.85, 0.75},
}); err != nil {
return err
}
if err := g.terrain.SetSplat(g.splatImage()); err != nil {
return err
}
pv, pi := gfx.PlaneMesh(1)
if g.water, err = ctx.Gfx.NewMesh(pv, pi); err != nil {
return err
}
cv, ci := gfx.CylinderMesh(16)
if g.tower, err = ctx.Gfx.NewMesh(cv, ci); err != nil {
return err
}
kv, ki := gfx.ConeMesh(16)
if g.roof, err = ctx.Gfx.NewMesh(kv, ki); err != nil {
return err
}
sv, si := gfx.SphereMesh(8, 12)
if g.ember, err = ctx.Gfx.NewMesh(sv, si); err != nil {
return err
}
// Rocks: a fine sphere near, a coarse one far, nothing beyond.
fine, fineIdx := gfx.SphereMesh(16, 32)
coarse, coarseIdx := gfx.FlatShaded(gfx.SphereMesh(5, 8))
fineMesh, err := ctx.Gfx.NewMesh(fine, fineIdx)
if err != nil {
return err
}
coarseMesh, err := ctx.Gfx.NewMesh(coarse, coarseIdx)
if err != nil {
return err
}
g.rocks = gfx.NewLOD([]*gfx.Mesh{fineMesh, coarseMesh, nil}, []float32{25, 70})
if g.tree, err = ctx.Gfx.NewTexture(treeImage(), gfx.TextureOptions{}); err != nil {
return err
}
// The pines are a model up close and a baked impostor beyond thirty
// units: twelve views around the tree in one atlas, so the far half
// of the wood costs one quad each and one instanced draw between them.
if g.pine, err = ctx.Gfx.LoadModel(pineDocument()); err != nil {
return err
}
if g.pineFar, err = ctx.Gfx.BakeImpostor(g.pine, gfx.ImpostorOptions{Views: 12, Resolution: 96, Pitch: lin.Radians(20), Light: sunlight(20)}); err != nil {
return err
}
g.pineFar.Distance = 30
g.scatter()
for _, p := range [][2]float32{{-30, 20}, {25, -12}, {12, 30}, {-25, -25}} {
g.fires = append(g.fires, lin.V3(p[0], g.terrain.Height(p[0], p[1])+0.3, p[1]))
}
g.towerAt = lin.V3(0, g.terrain.Height(0, -30), -30)
g.yaw, g.pitch, g.dist = 0.6, 0.42, 48
ctx.Gfx.SetPost(gfx.PostSettings{Exposure: 1, Saturation: 1.05, Contrast: 1, Bloom: 0.15})
return nil
}
Scattering by asking the ground
Nothing here knows the height function. Everything placed on the
terrain asks it: Height for where the ground is at a point and
Normal for which way it faces, so a tree only lands where the ground
is above the waterline, below the snow and no steeper than about
twenty-five degrees. That is the same pair of queries a game uses to
drop an item, stand a unit or refuse to build.
// scatter places the trees, pines and rocks on gentle land above the
// water, asking the terrain where the ground is and which way it faces.
func (g *game) scatter() {
r := rand.New(rand.NewSource(7))
gentle := func(x, z float32) (float32, bool) {
h := g.terrain.Height(x, z)
return h, h > 0.8 && h < 5.5 && g.terrain.Normal(x, z).Y > 0.9
}
for len(g.trees) < 400 {
x, z := (r.Float32()-0.5)*110, (r.Float32()-0.5)*110
if h, ok := gentle(x, z); ok {
g.trees = append(g.trees, lin.V3(x, h-0.1, z))
}
}
for len(g.pineAt) < 120 {
x, z := (r.Float32()-0.5)*110, (r.Float32()-0.5)*110
if h, ok := gentle(x, z); ok {
s := 0.8 + r.Float32()*0.6
g.pineAt = append(g.pineAt, gfx.Transform{Position: lin.V3(x, h, z), Scale: lin.V3(s, s, s)}.
Rotated(lin.V3(0, 1, 0), r.Float32()*6.28))
}
}
for len(g.rockAt) < 80 {
x, z := (r.Float32()-0.5)*112, (r.Float32()-0.5)*112
if h := g.terrain.Height(x, z); h > 0.3 {
s := 0.4 + r.Float32()*1.2
t := gfx.Transform{Position: lin.V3(x, h-s*0.3, z), Scale: lin.V3(s, s*0.6, s*(0.7+r.Float32()*0.6))}
g.rockAt = append(g.rockAt, t.Rotated(lin.V3(0, 1, 0), r.Float32()*6.28))
}
}
}
Shutdown
The terrain frees its chunk meshes, its shader and its splat texture, and the impostor frees its atlas. The layer textures belong to the game, so it destroys those itself, and so does everything else it made.
func (g *game) Shutdown(ctx *engine.Context) {
g.font.Destroy()
g.terrain.Destroy()
g.pineFar.Destroy()
g.pine.Destroy()
for _, m := range []*gfx.Mesh{g.water, g.tower, g.roof, g.ember} {
m.Destroy()
}
for _, l := range g.rocks.Levels {
if l.Mesh != nil {
l.Mesh.Destroy()
}
}
for _, t := range g.layers {
t.Destroy()
}
g.tree.Destroy()
}
Digging
Terrain.Heights hands back the terrain's own sample grid, so an edit
is a plain write into a []float32. Update then rebuilds only the
chunks covering the samples that changed, at every one of their levels,
and recomputes their normals and bounds. The loop tracks the rectangle
it touched to limit the rebuild; the number of chunks depends on the
crater's position and which chunk boundaries it crosses.
// dig lowers the terrain around a point and rebuilds the chunks it
// touched. Terrain.Heights is the terrain's own sample grid, so the edit
// is a write into it followed by Update over the samples that changed.
func (g *game) dig(at lin.Vec3, radius, depth float32) error {
h := g.terrain.Heights()
minX, minZ, maxX, maxZ := cols, rows, 0, 0
for z := range rows {
for x := range cols {
wx, wz := float32(x-cols/2)*cell, float32(z-rows/2)*cell
d := float32(math.Hypot(float64(wx-at.X), float64(wz-at.Z)))
if d >= radius {
continue
}
t := 1 - d/radius
h[z*cols+x] -= depth * t * t
minX, minZ = min(minX, x), min(minZ, z)
maxX, maxZ = max(maxX, x), max(maxZ, z)
}
}
if minX > maxX {
return nil
}
return g.terrain.Update(minX, minZ, maxX, maxZ)
}
Update: the camera and the click
The camera is three numbers driven by the mouse. A left click turns the
cursor into a world ray with Camera.ScreenRay, which takes the view
size and so works from Update, and Terrain.Raycast walks that ray
over the heightfield to find where it first passes under the ground.
One crater is dug a second in, so a screenshot has something to show.
func (g *game) Update(ctx *engine.Context) error {
in := ctx.Input
if in.KeyPressed(input.KeyEscape) || (g.seconds > 0 && ctx.Time >= g.seconds) {
ctx.Quit()
}
if in.MouseDown(input.MouseRight) {
dx, dy := in.MouseDelta()
g.yaw -= dx * 0.005
g.pitch = lin.Clamp(g.pitch-dy*0.005, 0.1, 1.4)
} else {
g.yaw += float32(ctx.Delta) * 0.05
}
if _, dy := in.Scroll(); dy != 0 {
g.dist = lin.Clamp(g.dist-dy*2, 10, 120)
}
if in.MousePressed(input.MouseLeft) {
mx, my := in.Mouse()
vw, vh := ctx.Gfx.View()
ray := gfx.OrbitCamera(lin.V3(0, 2, 0), g.yaw, g.pitch, g.dist).ScreenRay(mx, my, vw, vh)
if hit, ok := g.terrain.Raycast(ray, 0); ok {
if err := g.dig(hit, 5, 2); err != nil {
return err
}
}
}
if !g.dug && ctx.Time >= 1 {
g.dug = true
if err := g.dig(lin.V3(18, 0, 8), 7, 3); err != nil {
return err
}
}
if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
ctx.Screenshot(g.shot)
g.shotDone = true
}
return nil
}
Draw
The frame sets the camera and the shared light, adds the flickering
campfires and the sweeping searchlight, then draws. DrawTerrain is one
call that queues a draw per chunk at the level its distance deserves;
the frustum then culls the chunks behind the camera like any other
draws.
The pines go through DrawModelImpostor, which picks the model or the
impostor per tree by its distance from the camera. The billboard trees
are culled by hand first, because the cheapest draw is the one never
built: Frustum().ContainsSphere on each tree's own bounding sphere
skips more than a third of them from most angles, and the corner counts
say how many.
func (g *game) Draw(ctx *engine.Context) error {
gr := ctx.Gfx
t := float32(ctx.Time)
cam := gfx.OrbitCamera(lin.V3(0, 2, 0), g.yaw, g.pitch, g.dist)
gr.SetCamera(cam)
gr.SetLight(sunlight(cam.Position.Y))
// Campfires flicker; the tower's searchlight sweeps.
for i, f := range g.fires {
flick := 0.8 + 0.2*float32(math.Sin(float64(t)*9+float64(i)))
gr.AddPointLight(f.Add(lin.V3(0, 0.6, 0)), gfx.Color{R: 4 * flick, G: 2.2 * flick, B: 0.8 * flick, A: 1}, 12)
gr.DrawMeshAt(g.ember, gfx.Material{BaseColor: gfx.Color{R: 1, G: 0.5, B: 0.15, A: 1}, Emissive: 3 * flick}, gfx.Transform{Position: f, Scale: lin.V3(0.35, 0.25, 0.35)})
}
beam := lin.V3(float32(math.Cos(float64(t)*0.6)), -0.45, float32(math.Sin(float64(t)*0.6)))
top := g.towerAt.Add(lin.V3(0, 6.2, 0))
gr.AddSpotLight(top, beam, gfx.Color{R: 9, G: 8.5, B: 6, A: 1}, 60, lin.Radians(14), lin.Radians(28))
// The terrain queues one draw per chunk, each at the resolution its
// distance deserves; the frustum culls the chunks behind the camera.
gr.DrawTerrain(g.terrain)
gr.DrawMesh(g.water, gfx.Material{BaseColor: gfx.Color{R: 0.1, G: 0.32, B: 0.6, A: 0.65}, Blend: true, Roughness: 0.12}, lin.Translate(lin.V3(0, 0, 0)).Mul(lin.Scale(lin.V3(200, 1, 200))))
gr.DrawMeshAt(g.tower, gfx.Material{BaseColor: gfx.RGB(120, 100, 80), Roughness: 0.8}, gfx.Transform{Position: g.towerAt.Add(lin.V3(0, 3, 0)), Scale: lin.V3(0.9, 3, 0.9)})
gr.DrawMeshAt(g.roof, gfx.Material{BaseColor: gfx.RGB(150, 50, 40), Roughness: 0.7}, gfx.Transform{Position: g.towerAt.Add(lin.V3(0, 7, 0)), Scale: lin.V3(1.5, 1, 1.5)})
gr.DrawText3D(g.font, "Watchtower", g.towerAt.Add(lin.V3(0, 9, 0)), 0.05, gfx.White, false, gfx.TextOptions{})
gr.DrawText3D(g.font, "Lake", lin.V3(-8, 1.2, 6), 0.06, gfx.Color{R: 0.8, G: 0.9, B: 1, A: 1}, false, gfx.TextOptions{})
rock := gfx.Material{BaseColor: gfx.RGB(115, 110, 105), Roughness: 0.9}
for _, at := range g.rockAt {
gr.DrawLODAt(g.rocks, rock, at)
}
// Each pine is a model within thirty units and its baked impostor
// beyond, chosen per tree by DrawModelImpostor.
for _, at := range g.pineAt {
gr.DrawModelImpostor(g.pine, g.pineFar, at)
}
// Billboard trees the camera cannot see are not even queued.
fr := gr.Frustum()
g.skipped = 0
for _, p := range g.trees {
if !fr.ContainsSphere(p.Add(lin.V3(0, 1.5, 0)), 2) {
g.skipped++
continue
}
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})
}
// A scout camera's view volume, drawn as lines.
scout := gfx.Camera{Position: lin.V3(30, 10, 25), Target: lin.V3(10, 0, 5), FovY: lin.Radians(40), Far: 30}
gr.DrawWireFrustum(scout, 16.0/9, gfx.Color{R: 1, G: 0.9, B: 0.2, A: 1})
gr.DebugText3D(scout.Position, "scout")
s := gr.Stats()
gr.DebugText(10, 10, fmt.Sprintf("draws %d instances %d culled %d trees skipped %d near chunk level %d",
s.Draws3D, s.Instances, s.Culled, g.skipped, g.terrain.ChunkLevel(g.terrain.Chunks()/2)))
gr.DebugText(10, 28, "right-drag orbits, scroll zooms, click digs")
return nil
}
main
The window is resizable so the letterboxing and the aspect handling get
exercised. To run with the Vulkan validation layers, set
BUNYIP_VALIDATION=1 in the environment.
func main() {
seconds := flag.Float64("seconds", 0, "exit after this many seconds")
shot := flag.String("shot", "", "write a screenshot to this PNG")
flag.Parse()
err := engine.Run(engine.Config{Title: "Bunyip terrain", Width: 1024, Height: 640, Resizable: true},
&game{seconds: *seconds, shot: *shot})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}