Example examples/physics3d
Physics 3D

Five hundred cubes fall into a walled pen and settle into a pile. Each
one is an entity with a gfx.Transform, a phys.Body3 and a
phys.Collider3, and one system steps them all. Wood grain, brushed
metal and molded plastic give the cubes visible surface detail; gold,
clearcoat paint, velvet, glass and glowing cubes retain the material
showcase. The ground and walls use tiled concrete.
Run it:
CGO_ENABLED=0 go run ./examples/physics3d -seconds 3 -shot out.png
The flags are -seconds N and -shot file.png. Dragging orbits the
camera, the wheel zooms, hovering highlights the cube under the pointer,
R drops the cubes again and Escape quits.
Textures and material response
The texture images are generated in Go once during initialization. No download or external asset is needed. Cubes share textures by material family, so resetting the pile creates entities without allocating another set of GPU textures.
Each textured family has three maps:
- Albedo supplies the surface colour.
- A tangent-space normal map gives grain and small scratches a lighting response.
- A packed map stores roughness in green and metallic weight in blue.
Colour textures use the normal sRGB path. Normal and metallic/roughness
maps use TextureOptions.Data because their channels contain numeric
data. Linear filtering, mipmaps and repeating coordinates keep detail
stable as the camera moves; the pen tiles its textures across the larger
surfaces.
These are visual materials. The rigid bodies retain the same mass,
friction and restitution, so a wooden cube does not simulate a different
density from a metal one. Metallic, Roughness, Clearcoat,
Sheen and Transmission affect rendering. Hovering raises a copy of
the cube's emissive value without changing its stored material.
See Materials for the renderer's fields and 3D graphics for lights and cameras.
Simulation and controls
The ground and four walls have static box colliders. Each cube has a dynamic body and a unit box collider. The physics system advances on the engine's fixed timestep; a ray from the pointer highlights the first collider hit. All movement and collision behaviour is handled by the physics package.
The seeded spawn sequence is independent of texture generation. Reset continues that sequence and reuses the same meshes and texture resources. Textures are created through the graphics owner and released with the example's other resources.
Main program
// Command physics3d drops five hundred cubes into a pile. Each is an
// entity with a transform, a rigid body and a box collider; the physics
// system stacks them with friction and restitution. Drag to orbit, hover
// to highlight the cube under the pointer (a raycast), R drops them
// again, Escape quits.
package main
import (
"flag"
"fmt"
"os"
"golang.org/x/image/font/gofont/goregular"
"github.com/matjam/bunyip/ecs"
"github.com/matjam/bunyip/engine"
"github.com/matjam/bunyip/gfx"
"github.com/matjam/bunyip/input"
"github.com/matjam/bunyip/lin"
"github.com/matjam/bunyip/phys"
"github.com/matjam/bunyip/rng"
"github.com/matjam/bunyip/ui"
)
const count = 500
type cube struct{ Material gfx.Material }
// palette is the cube colours: strong hues so each surface reads.
var palette = []gfx.Color{
gfx.RGB(220, 50, 50), gfx.RGB(240, 140, 30), gfx.RGB(240, 220, 60), gfx.RGB(60, 190, 80),
gfx.RGB(40, 200, 200), gfx.RGB(50, 110, 240), gfx.RGB(150, 70, 230), gfx.RGB(240, 100, 180),
gfx.RGB(240, 240, 240),
}
type game struct {
seconds float64
shot string
font *gfx.Font
ui *ui.Context
world *ecs.World
cubes *ecs.Query2[gfx.Transform, cube]
mesh *gfx.Mesh
materials materialLibrary
random *rng.Rand
hover ecs.Entity
yaw float32
pitch float32
dist float32
lastX float32
lastY float32
dragging bool
shotDone bool
}
func (g *game) Init(ctx *engine.Context) error {
var err error
if g.font, err = ctx.Gfx.NewFont(goregular.TTF, 15, gfx.FontOptions{}); err != nil {
return err
}
g.ui = ui.New(ctx.Gfx, ui.DarkTheme(g.font))
cv, ci := gfx.CubeMesh()
if g.mesh, err = ctx.Gfx.NewMesh(cv, ci); err != nil {
return err
}
if err := g.materials.init(ctx.Gfx); err != nil {
return err
}
g.random = rng.New(3)
g.yaw, g.pitch, g.dist = 0.7, 0.4, 40
w := ecs.NewWorld()
g.world = w
g.cubes = w.Query2[gfx.Transform, cube]()
w.SetResource(phys.Settings3{Gravity: lin.V3(0, -9.8, 0), Substeps: 4, Iterations: 8})
// The ground and four low walls are static colliders: no body.
w.SpawnWith(gfx.Transform{}, phys.Collider3{Shape: phys.Box3{Half: lin.V3(30, 0.5, 30)}})
for _, wall := range []lin.Vec3{{X: 12, Y: 1.5, Z: 0}, {X: -12, Y: 1.5, Z: 0}} {
w.SpawnWith(gfx.Transform{Position: wall}, phys.Collider3{Shape: phys.Box3{Half: lin.V3(0.5, 1.5, 12)}})
}
for _, wall := range []lin.Vec3{{X: 0, Y: 1.5, Z: 12}, {X: 0, Y: 1.5, Z: -12}} {
w.SpawnWith(gfx.Transform{Position: wall}, phys.Collider3{Shape: phys.Box3{Half: lin.V3(12, 1.5, 0.5)}})
}
w.AddSystem("physics", phys.System3)
g.drop()
return nil
}
// drop respawns the cubes in a loose column above the ground.
func (g *game) drop() {
w := g.world
g.cubes.Each(func(e ecs.Entity, _ *gfx.Transform, _ *cube) { w.Despawn(e) })
for i := range count {
x := float32(i%8)*1.3 - 4.5 + g.random.Between(-0.2, 0.2)
z := float32((i/8)%8)*1.3 - 4.5 + g.random.Between(-0.2, 0.2)
y := 3 + float32(i/64)*2.5 + g.random.Between(0, 1)
body := phys.Dynamic3(1)
body.Friction, body.Restitution = 0.6, 0.05
c := palette[g.random.Intn(len(palette))]
w.SpawnWith(
gfx.Transform{Position: lin.V3(x, y, z), Rotation: lin.AxisAngle(lin.V3(g.random.Float(), g.random.Float(), g.random.Float()).Norm(), g.random.Float()*3)},
body,
phys.Collider3{Shape: phys.Box3{Half: lin.V3(0.5, 0.5, 0.5)}},
cube{Material: g.materials.cubeMaterial(cubeKinds[g.random.Intn(len(cubeKinds))], c)},
)
}
}
func (g *game) Shutdown(ctx *engine.Context) {
for _, texture := range g.materials.textures {
texture.Destroy()
}
g.mesh.Destroy()
g.font.Destroy()
}
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 g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
ctx.Screenshot(g.shot)
g.shotDone = true
}
if in.KeyPressed(input.KeyR) {
g.drop()
}
x, y := in.Mouse()
if in.MousePressed(input.MouseLeft) && !g.ui.WantsMouse() {
g.dragging = true
}
if in.MouseReleased(input.MouseLeft) {
g.dragging = false
}
if g.dragging {
g.yaw += (x - g.lastX) * 0.01
g.pitch = lin.Clamp(g.pitch+(y-g.lastY)*0.01, 0.05, 1.5)
}
g.lastX, g.lastY = x, y
_, dy := in.Scroll()
g.dist = lin.Clamp(g.dist-float32(dy)*2, 8, 120)
step := ctx.Profile("physics")
g.world.Update(ctx.Delta)
step.End()
return nil
}
func (g *game) Draw(ctx *engine.Context) error {
gr := ctx.Gfx
w := g.world
gr.SetCamera(gfx.OrbitCamera(lin.V3(0, 3, 0), g.yaw, g.pitch, g.dist))
gr.SetLight(gfx.Light{Direction: lin.V3(-0.5, -1, -0.3), Color: gfx.Color{R: 2.4, G: 2.3, B: 2.1, A: 1},
Sky: gfx.Sky{Zenith: gfx.Color{R: 0.3, G: 0.35, B: 0.5, A: 1}, Ground: gfx.Color{R: 0.15, G: 0.12, B: 0.1, A: 1}}, Shadows: true, ShadowDistance: 60})
ground := g.materials.concrete
ground.UVTransform = lin.Scale2(20, 20)
gr.DrawMesh(g.mesh, ground, lin.Translate(lin.V3(0, 0, 0)).Mul(lin.Scale(lin.V3(60, 1, 60))))
wallMaterial := g.materials.concrete
wallMaterial.BaseColor = gfx.RGB(190, 190, 200)
wallMaterial.UVTransform = lin.Scale2(8, 1)
for _, wall := range []struct{ pos, half lin.Vec3 }{{lin.V3(12, 1.5, 0), lin.V3(0.5, 1.5, 12)}, {lin.V3(-12, 1.5, 0), lin.V3(0.5, 1.5, 12)}, {lin.V3(0, 1.5, 12), lin.V3(12, 1.5, 0.5)}, {lin.V3(0, 1.5, -12), lin.V3(12, 1.5, 0.5)}} {
gr.DrawMesh(g.mesh, wallMaterial, lin.Translate(wall.pos).Mul(lin.Scale(wall.half.Mul(2))))
}
// The cube under the pointer, found by a raycast into the physics world.
mx, my := ctx.Input.Mouse()
ray := gr.ScreenRay(float32(mx), float32(my))
g.hover = ecs.None
if hit, ok := phys.Raycast3(w, phys.Ray3{Origin: ray.Origin, Dir: ray.Dir.Mul(200)}, 0); ok {
g.hover = hit.Entity
}
g.cubes.Each(func(e ecs.Entity, t *gfx.Transform, c *cube) {
mat := c.Material
if e == g.hover {
mat.Emissive = 1.5
}
gr.DrawMeshAt(g.mesh, mat, *t)
})
u := g.ui
u.Begin(ctx.Input, func() {
u.Panel("500 cubes", ui.Rect{X: 12, Y: ctx.Height - 140, W: 340, H: 128}, func() {
ms := 0.0
if len(ctx.Stats.Scopes) > 0 {
ms = ctx.Stats.Scopes[0].MS
}
u.Label(fmt.Sprintf("physics %.2f ms/frame; drag orbits, scroll zooms; wood, brushed metal, plastic, gold, paint, velvet, glass and glow", ms))
if u.Button("Drop again (R)") {
g.drop()
}
})
})
return nil
}
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 physics: 500 cubes", Width: 1024, Height: 680, Resizable: true},
&game{seconds: *seconds, shot: *shot})
if err != nil {
fmt.Fprintln(os.Stderr, "physics3d:", err)
os.Exit(1)
}
}
Shared procedural materials
The periodic height fields also generate normals using wrapped neighbouring samples. A local integer hash supplies repeatable noise without consuming the simulation's random stream. The nine spawn slots retain the original weights, with two former plastic slots now producing wood.
package main
import (
"fmt"
"image"
"image/color"
"math"
"github.com/matjam/bunyip/gfx"
"github.com/matjam/bunyip/lin"
)
type cubeKind uint8
const (
brushedMetal cubeKind = iota
gold
carPaint
velvet
glass
glowing
wood
plastic
)
// Nine weighted slots preserve the simulation's seeded random sequence.
// Wood takes two of the former plastic slots so grain is easy to find.
var cubeKinds = [...]cubeKind{brushedMetal, gold, carPaint, velvet, glass, glowing, wood, wood, plastic}
// materialLibrary shares twelve GPU images across all cubes and the pen.
// Images are generated once during Init; dropping cubes only copies materials.
type materialLibrary struct {
wood, metal, plastic, concrete gfx.Material
textures []*gfx.Texture
}
func (m *materialLibrary) init(gr *gfx.Graphics) error {
for _, surface := range []struct {
name string
material *gfx.Material
pixel func(float64, float64) (color.RGBA, float64, float64)
metallic uint8
}{
{"wood", &m.wood, woodPixel, 0},
{"brushed metal", &m.metal, metalPixel, 255},
{"molded plastic", &m.plastic, plasticPixel, 0},
{"concrete", &m.concrete, concretePixel, 0},
} {
albedo, normal, roughness := surfaceImages(surface.pixel, surface.metallic)
for _, channel := range []struct {
name string
src *image.RGBA
dst **gfx.Texture
data bool
}{
{"albedo", albedo, &surface.material.Texture, false},
{"normal", normal, &surface.material.NormalTexture, true},
{"metal/roughness", roughness, &surface.material.MetalRoughTexture, true},
} {
texture, err := gr.NewTexture(channel.src, gfx.TextureOptions{Linear: true, Repeat: true, Data: channel.data})
if err != nil {
// Graphics owns prior uploads and also releases them if Init fails.
return fmt.Errorf("physics3d: %s %s texture: %w", surface.name, channel.name, err)
}
*channel.dst = texture
m.textures = append(m.textures, texture)
}
// The map carries the absolute roughness, so its factor must be one.
surface.material.Roughness = 1
}
m.wood.Clearcoat, m.wood.ClearcoatRoughness = 0.15, 0.35
return nil
}
// cubeMaterial retains the optical showcase alongside the textured surfaces.
func (m *materialLibrary) cubeMaterial(kind cubeKind, c gfx.Color) gfx.Material {
switch kind {
case brushedMetal:
mat := m.metal
mat.BaseColor = c
return mat
case gold:
return gfx.Material{BaseColor: gfx.RGB(255, 200, 90), Metallic: 1, Roughness: 0.1}
case carPaint:
return gfx.Material{BaseColor: c, Roughness: 0.5, Clearcoat: 1, ClearcoatRoughness: 0.05}
case velvet:
return gfx.Material{BaseColor: c, Roughness: 0.95, Sheen: gfx.RGB(255, 255, 255), SheenRoughness: 0.5}
case glass:
return gfx.Material{Roughness: 0.05, Transmission: 1, IOR: 1.5, Thickness: 1, AttenuationColor: c, AttenuationDistance: 2}
case glowing:
return gfx.Material{BaseColor: c, Roughness: 0.6, Emissive: 1.2}
case wood:
return m.wood
default:
mat := m.plastic
mat.BaseColor = c
mat.UVTransform = lin.Scale2(2, 2)
return mat
}
}
// surfaceImages samples a periodic surface into albedo, tangent-space normals
// and glTF G-roughness/B-metallic maps. Wrapped height differences keep the
// normal map continuous at tile boundaries; linear filtering supplies mipmaps.
func surfaceImages(pixel func(float64, float64) (color.RGBA, float64, float64), metallic uint8) (*image.RGBA, *image.RGBA, *image.RGBA) {
const size = 256
bounds := image.Rect(0, 0, size, size)
albedo, normal, roughness := image.NewRGBA(bounds), image.NewRGBA(bounds), image.NewRGBA(bounds)
heights := make([]float64, size*size)
for y := range size {
for x := range size {
c, h, r := pixel(float64(x)/size, float64(y)/size)
albedo.SetRGBA(x, y, c)
heights[y*size+x] = h
roughness.SetRGBA(x, y, color.RGBA{R: 255, G: channelByte(r), B: metallic, A: 255})
}
}
for y := range size {
for x := range size {
dx := (heights[y*size+(x+1)%size] - heights[y*size+(x+size-1)%size]) * 2
dy := (heights[((y+1)%size)*size+x] - heights[((y+size-1)%size)*size+x]) * 2
length := math.Sqrt(dx*dx + dy*dy + 1)
normal.SetRGBA(x, y, color.RGBA{R: channelByte(0.5 - dx/length*0.5), G: channelByte(0.5 - dy/length*0.5), B: channelByte(0.5 + 0.5/length), A: 255})
}
}
return albedo, normal, roughness
}
func woodPixel(u, v float64) (color.RGBA, float64, float64) {
warp := 0.10*math.Sin(2*math.Pi*v) + 0.025*math.Sin(4*math.Pi*v)
grain := 0.5 + 0.5*math.Sin(2*math.Pi*(u*7+warp))
fiber := 0.5 + 0.5*math.Sin(2*math.Pi*(u*43+warp*5))
pore := tileNoise(u, v, 96, 8)
tone := 0.25 + 0.55*grain + 0.12*fiber + 0.08*pore
return color.RGBA{R: uint8(92 + 108*tone), G: uint8(43 + 96*tone), B: uint8(18 + 56*tone), A: 255},
0.25*grain + 0.05*fiber + 0.04*pore, 0.48 + 0.22*(1-grain)
}
func metalPixel(u, v float64) (color.RGBA, float64, float64) {
brush := tileNoise(u, v, 128, 4)
streak := tileNoise(u, v, 32, 2)
scratch := math.Pow(0.5+0.5*math.Sin(2*math.Pi*(u*59+0.02*math.Sin(2*math.Pi*v))), 16)
tone := 0.73 + 0.16*brush + 0.08*streak - 0.10*scratch
b := channelByte(tone)
return color.RGBA{R: b, G: b, B: b, A: 255}, 0.14*brush - 0.08*scratch, 0.23 + 0.26*brush + 0.10*scratch
}
func plasticPixel(u, v float64) (color.RGBA, float64, float64) {
grain := tileNoise(u, v, 64, 64)
b := channelByte(0.94 + 0.06*grain)
return color.RGBA{R: b, G: b, B: b, A: 255}, 0.10 * grain, 0.57 + 0.17*grain
}
func concretePixel(u, v float64) (color.RGBA, float64, float64) {
mottle := tileNoise(u, v, 5, 5)
aggregate := tileNoise(u, v, 32, 32)
pores := math.Max(0, tileNoise(u, v, 96, 96)-0.65) * 2
b := channelByte(0.53 + 0.08*mottle + 0.05*aggregate - 0.06*pores)
return color.RGBA{R: b, G: b, B: b + 5, A: 255}, 0.14*aggregate - 0.22*pores, 0.80 + 0.16*aggregate
}
// tileNoise interpolates a wrapping integer lattice. Its local hash leaves
// the physics RNG untouched, and smooth interpolation avoids pixel speckle.
func tileNoise(u, v float64, nx, ny int) float64 {
x, y := u*float64(nx), v*float64(ny)
ix, iy := int(math.Floor(x)), int(math.Floor(y))
x, y = x-math.Floor(x), y-math.Floor(y)
x, y = x*x*(3-2*x), y*y*(3-2*y)
hash := func(a, b int) float64 {
h := uint32((a%nx+nx)%nx)*0x8da6b343 ^ uint32((b%ny+ny)%ny)*0xd8163841 ^ 0xcb1ab31f
h ^= h >> 13
h *= 0x85ebca6b
h ^= h >> 16
return float64(h&0xffff) / 65535
}
a, b := hash(ix, iy), hash(ix+1, iy)
c, d := hash(ix, iy+1), hash(ix+1, iy+1)
return (a+(b-a)*x)*(1-y) + (c+(d-c)*x)*y
}
func channelByte(v float64) uint8 {
return uint8(math.Max(0, math.Min(1, v))*255 + 0.5)
}