Bunyip a game engine in Go GitHub

Physics

The phys package simulates rigid bodies on the ECS in two and three dimensions. Both dimensions use the same component model with dimension-specific types. A transform holds the position and rotation, a body holds the mass and velocity, and a collider holds the shape. One system per dimension steps the simulation.

Setting up

w.SetResource(phys.Settings3{Gravity: lin.V3(0, -9.8, 0)})
w.AddSystem("physics", phys.System3)

// A static floor: a collider with no body never moves.
w.SpawnWith(gfx.Transform{}, phys.Collider3{Shape: phys.Box3{Half: lin.V3(20, 0.5, 20)}})

// A crate: a dynamic body with a box collider.
w.SpawnWith(gfx.At(0, 5, 0), phys.Dynamic3(2), phys.Collider3{Shape: phys.Box3{Half: lin.V3(0.5, 0.5, 0.5)}})

2D is the same with Settings2, System2, gfx.Transform2, Body2 and Collider2. Screen coordinates grow downward, so 2D gravity is usually positive, in view units per second squared. 3D uses +Y up. Gravity defaults to zero in both dimensions, and all step durations are seconds. Collider sizes are in world units; transform scale is ignored, so change shape dimensions to resize a collider.

Bodies

Dynamic2 and Dynamic3 use the supplied mass, restitution 0.1, friction 0.5 and gravity scale 1. Set Restitution for bounce, Friction for grip and LinearDamping to reduce speed over time. Kinematic2 and Kinematic3 move by their velocity and push other bodies without being pushed, which suits platforms, doors and paddles. A body with zero mass, or an entity with only a collider and transform, is static unless marked kinematic.

To apply a force between frames, call AddForce or AddTorque. To change the velocity at once, call AddImpulse. GravityScale scales how strongly gravity pulls on one body, LockRotation stops a body rotating, and setting Sleeping freezes a body until the game clears it again. Zero GravityScale means 1, so it does not disable gravity. Wake only clears automatic sleep and does not clear Sleeping.

// 2D: a paddle that moves under the game's control and pushes what it meets.
paddle := w.SpawnWith(gfx.At2(700, 500), phys.Kinematic2(),
	phys.Collider2{Shape: phys.Box2{HalfW: 90, HalfH: 10}})
if b, ok := w.Get[phys.Body2](paddle); ok {
	b.Vel = lin.V2(200, 0)
}

// 3D: a bouncy crate, shoved once at spawn.
crate := phys.Dynamic3(2)
crate.Restitution, crate.Friction, crate.LinearDamping = 0.4, 0.6, 0.1
e := w.SpawnWith(gfx.At(0, 5, 0), crate,
	phys.Collider3{Shape: phys.Box3{Half: lin.V3(0.5, 0.5, 0.5)}})
if b, ok := w.Get[phys.Body3](e); ok {
	b.AddImpulse(lin.V3(0, 0, -6))
}

Shapes

2D: Circle, Box2, convex Polygon2, Capsule2, and for terrain Edge2 and Chain2 (a polyline of edges, optionally closed). 3D: Sphere, Box3, Capsule, ConvexHull from any point cloud, MeshShape for static triangle geometry such as a level or a heightfield, and Compound3 for parts with their own offsets and rotations. Build a MeshShape with NewMeshShape so its triangle tree is built once, and draw the same vertices as a gfx mesh.

Dynamic convex shapes collide with one another and with static mesh triangles. Sphere and box pairs have dedicated tests; other convex pairs use support functions (GJK for distance, EPA for penetration). Mesh-to-mesh collision and dynamic triangle meshes are not supported. NewMeshShape retains its input slices. Leave both vertices and indices unchanged after construction; construct a new mesh shape to edit them.

Each collider has an Offset from the transform, a Trigger flag and Layers. Two colliders collide only when each one's Layer bits appear in the other's Mask. Use this to let a bullet pass through bodies on its own team. Zero Layer or Mask means all bits. Offset is in the transform's local frame and rotates with it.

// 2D: a terrain outline and a triangle that lands on it.
w.SpawnWith(gfx.Transform2{}, phys.Collider2{Shape: phys.Chain2{
	Points: []lin.Vec2{{X: 0, Y: 300}, {X: 200, Y: 260}, {X: 400, Y: 330}}}})
w.SpawnWith(gfx.At2(120, 40), phys.Dynamic2(1), phys.Collider2{
	Shape: phys.Polygon2{Points: []lin.Vec2{{X: 0, Y: -20}, {X: 17, Y: 10}, {X: -17, Y: 10}}}})

// 3D: a static level mesh, and a hammer made of two boxes on one body.
w.SpawnWith(gfx.Transform{}, phys.Collider3{Shape: phys.NewMeshShape(vertices, indices)})
w.SpawnWith(gfx.At(0, 4, 0), phys.Dynamic3(5), phys.Collider3{
	Shape: phys.Compound3{Parts: []phys.Part3{
		{Shape: phys.Box3{Half: lin.V3(0.06, 0.5, 0.06)}},
		{Shape: phys.Box3{Half: lin.V3(0.3, 0.12, 0.12)}, Offset: lin.V3(0, 0.5, 0)},
	}},
	Layers: phys.Layers{Layer: 2, Mask: 1}})

Collisions and triggers

The system emits a Collision2 or Collision3 event for each pair of colliders in contact during an update, with the contact point, normal, depth and impulse, and a Trigger2 or Trigger3 event while a trigger overlaps something. Each collision pair is reported once per update, using the first substep in which it touches. If both colliders are triggers, each receives an event for that pair. Impulse is the total normal impulse from that substep, not a sum over the whole update. Events do not distinguish contact begin and end. Read them in a later system:

w.AddSystem("damage", func(w *ecs.World, dt float64) {
	for _, hit := range w.Events[phys.Collision3]() {
		if hit.Impulse > 50 { /* a hard landing */ }
	}
})

Queries

Raycast2 and Raycast3 find the nearest collider along a ray, which covers picking, line of sight and ground checks. RaycastAll2 and RaycastAll3 return every hit in order of distance. Pass a gfx.ScreenRay to Raycast3 to find the body under the pointer. OverlapSphere3, OverlapBox3 and OverlapShape3, and their 2D counterparts, list every collider inside a volume, which covers explosion radii and selection boxes. ShapeCast2 and ShapeCast3 sweep a shape along a direction and report the first collider it would hit and how far along the sweep it got. Nearest2 and Nearest3 find the closest collider to a point within a radius.

Ray2.Dir and Ray3.Dir are full displacements, not unit directions: the query tests the segment from Origin to Origin + Dir. Ray and shape casts return a Distance fraction from 0 to 1; overlap queries return penetration depth and nearest queries return world-space gap. Raycasts, shape casts and nearest queries ignore triggers; overlaps include them. Shape casts also ignore colliders overlapping at the start.

SignedDistance2 and SignedDistance3 measure a point against one shape placed in the world, without touching the entity world at all. They return the distance to the surface, negative inside, and the outward normal there, which is what code that pushes points out of solids needs. They understand Sphere, Box3, Capsule and compounds of those in 3D, and Circle, Box2, Polygon2 and Capsule2 in 2D, and report false for the rest.

A game that queries every frame can avoid the result slice by calling RaycastAll2Into, RaycastAll3Into, OverlapShape2Into or OverlapShape3Into, which append to a slice the caller keeps and hands back truncated with [:0]. Result and internal scratch buffers can be reused after they have grown to fit stable geometry:

g.hits = phys.RaycastAll3Into(g.hits[:0], w, ray, 0)
g.near = phys.OverlapShape3Into(g.near[:0], w, blast, pos, lin.Quat{}, 0)

The 3D query cache notices replaced colliders and edits to hull points or compound parts, including changes that preserve their outer bounds. Geometry changes refresh the cached snapshot; unchanged geometry reuses its placed parts without allocating.

Each collider's placement is kept between steps and queries, and the queries search a tree of the colliders' bounds, so a short ray or a character's sweep tests the few colliders near it rather than every collider in the level. The results and their order are the same as a walk over every collider would give. Because the game may move any collider by writing its transform, each query still walks the collider components once to notice what changed since the last step and places only those again; a character controller's move walks once for all of its sweeps and probes. Shape sweeps order their candidates along the sweep, so later candidates are skipped once a nearer hit is known. The 3D placed-shape cache rebuilds for motion, rotation or geometry changes and distinguishes recycled entity handles. Hull points and compound parts can be edited in place; immutable triangle meshes remain a separate case.

// The body under the pointer.
ray := gr.ScreenRay(mx, my)
if hit, ok := phys.Raycast3(w, phys.Ray3{Origin: ray.Origin, Dir: ray.Dir.Mul(200)}, 0); ok {
	hover = hit.Entity
}

// Everything an explosion caught, pushed away from the blast.
for _, h := range phys.OverlapSphere3(w, blast, 5, 0) {
	if b, ok := w.Get[phys.Body3](h.Entity); ok {
		b.AddImpulse(h.Point.Sub(blast).Norm().Mul(20))
	}
}

// A 2D ground check: sweep the player's circle a little way down.
_, grounded := phys.ShapeCast2(w, phys.Circle{Radius: 12}, pos, 0, lin.V2(0, 4), 0)

Joints

A joint is a component on its own entity that constrains two bodies. To constrain one body to a point in the world instead, leave the other side as ecs.None. Joints are solved in the same iterations as the contacts, so a chain of hinges holds together.

Joint What it allows Limits and drives
DistanceJoint2, DistanceJoint3 a fixed distance, or a range with Min and Max none
RevoluteJoint2, HingeJoint3 rotation about one axis MinAngle, MaxAngle, MotorSpeed with MaxMotorTorque
BallJoint3 rotation about any axis through a point ConeAngle, TwistAngle
PrismaticJoint2, PrismaticJoint3 sliding along one axis Min, Max, MotorSpeed with MaxMotorForce, Stiffness with Damping
WheelJoint2 sliding along one axis and free spin Min, Max, MotorSpeed with MaxMotorTorque, Frequency with DampingRatio
SpringJoint2, SpringJoint3 a damped pull toward a rest length none
FixedJoint2, FixedJoint3 nothing; a weld none
// 2D: a crate on a rope from a fixed point, and a wheel sprung to a cart.
w.SpawnWith(phys.DistanceJoint2{A: crate, B: ecs.None,
	AnchorB: lin.V2(400, 100), Max: 150})
w.SpawnWith(phys.SpringJoint2{A: cart, B: wheel,
	RestLength: 40, Stiffness: 30, Damping: 4})

A hinge or revolute joint measures its angle from the pose on its first step, positive by the right-hand rule about the axis. Angle(w) reads that angle. MinAngle and MaxAngle stop the joint at either end, so a door opens one way only; both zero means unlimited. MotorSpeed and MaxMotorTorque drive the joint towards a speed with bounded torque, for a wheel or a winch. A heavy load slows the motor and a limit stops it. In 3D a limit joins the solve a tenth of a radian before it is reached and from there only stops the joint closing the rest of the gap faster than one substep allows, so a joint resting at its limit meets the same constraint every substep rather than one that switches on and off.

w.SpawnWith(phys.HingeJoint3{A: axle, B: wheel, AxisA: lin.V3(1, 0, 0), AxisB: lin.V3(1, 0, 0),
	MotorSpeed: 10, MaxMotorTorque: 50})
w.SpawnWith(phys.HingeJoint3{A: frame, B: door, AnchorA: hingePos, AnchorB: lin.V3(-0.5, 0, 0),
	AxisA: lin.V3(0, 1, 0), AxisB: lin.V3(0, 1, 0), MinAngle: 0, MaxAngle: 1.6})

BallJoint3 holds two bodies together at a point and allows rotation in every direction. AxisB is the limb's axis in its own frame (local Y by default) and AxisA is the centre of its cone in the parent's frame (by default, the direction the limb pointed on the first step). ConeAngle limits how far the limb swings from that centre and TwistAngle limits how far it turns about itself. Angles(w) reads both.

PrismaticJoint2 and PrismaticJoint3 are sliders: a lift, a piston, a drawer, a sliding door. Axis is the slide direction in A's frame and a zero axis means local X. The translation is how far B's anchor sits from A's along that axis, so it is zero when the anchors meet, and Translation(w) reads it. Min and Max stop the travel, MotorSpeed with MaxMotorForce drives it, and Stiffness with Damping adds a spring that pulls the translation back toward zero.

WheelJoint2 is a wheel on a suspension. A is the chassis and B the wheel, which spins freely and slides along Axis in the chassis frame; a zero axis means local Y. AnchorA is where the wheel sits when the suspension is at rest. The spring is given as Frequency in hertz and DampingRatio, where 1 is critically damped, so the response is the same whatever the chassis weighs; zero frequency leaves the axis free. MotorSpeed with MaxMotorTorque drives the wheel's spin, which is how a car pulls itself along. Min and Max stop the suspension travel.

// A lift that runs up a rail between two floors.
w.SpawnWith(phys.PrismaticJoint3{A: ecs.None, B: platform, AnchorA: lin.V3(0, 0, 0),
	Axis: lin.V3(0, 1, 0), Min: 0, Max: 4, MotorSpeed: 1.5, MaxMotorForce: 4000})

// A driven wheel under the front of a car.
w.SpawnWith(phys.WheelJoint2{A: chassis, B: wheel, AnchorA: lin.V2(-0.8, -0.65),
	Axis: lin.V2(0, 1), Frequency: 4, DampingRatio: 0.7,
	MotorSpeed: -20, MaxMotorTorque: 20})

Ragdolls

NewRagdoll3(w, spec) spawns a humanoid of eleven capsules (pelvis, spine, head, upper arms, forearms, thighs, shins) joined by ball joints at the waist, neck, shoulders and hips and by one-way hinges at the elbows and knees. The joint limits stop the elbows and knees bending backwards. A zero RagdollSpec gives a figure 1.8 units tall with a mass of 70; Height scales it, and Mass, the bone sizes, Position (where its feet stand) and Rotation adjust the rest. By default the parts share a layer that collides with everything except other ragdoll parts.

The result names every part and joint (Parts, Joints, RagdollPelvis and the other part constants) so a game can draw them, and Bones records each part's size as built, so the mesh for a limb can be scaled to fit its collider. Pose places the parts to match an animated character's bones, which is how a game hands over from an animation to the ragdoll. Give it the world position and rotation of each part. The position is the part's centre, which is the bone's origin plus half the bone's Length along its rotated -Y axis. Despawn removes the whole figure.

r := phys.NewRagdoll3(w, phys.RagdollSpec{Position: lin.V3(0, 3, 0)})
head := r.Parts[phys.RagdollHead]

Continuous collision

A small fast body can pass through a thin wall between two steps. Set Body.CCD on bullets and other fast bodies to prevent that. The body is then swept against the static colliders each substep and stopped at the first one it would have crossed, and its bounding sphere is swept against the other moving bodies so two fast bodies meet rather than cross. The second test is conservative and does not reproduce each body's exact moving surface. CCD is an approximation, so retain suitable substeps for the speeds, dimensions and rotations in the game.

bullet := phys.Dynamic3(0.02)
bullet.CCD = true
bullet.GravityScale = 0.1
bullet.Vel = lin.V3(0, 0, -300)
w.SpawnWith(gfx.At(0, 1.5, 0), bullet,
	phys.Collider3{Shape: phys.Sphere{Radius: 0.02}})

Sleeping

When Settings.SleepTime is set, bodies that stay at rest for that long go to sleep. A sleeping body is neither integrated nor paired with other sleeping bodies. A contact or an impulse wakes it. Body.Asleep reports the state and Wake ends it early. Sleeping is off by default. A body counts as at rest while it moves slower than Settings.SleepThreshold, in units per second, and turns slowly enough that the point of its collider farthest from its centre does too; in 2D the angular speed is compared with the threshold directly. A stack of boxes settles below the threshold at the default solver quality, within a second or two of landing, and fifty ragdolls dropped on a floor are all asleep within eight seconds. A stack whose boxes are turned relative to each other keeps creeping into place for longer, and raising Substeps and Iterations settles it sooner.

w.SetResource(phys.Settings3{Gravity: lin.V3(0, -9.8, 0),
	SleepTime: 0.5})

// How much of the world has settled, for a debug readout.
resting := 0
w.Each(func(e ecs.Entity, b *phys.Body3) {
	if b.Asleep() {
		resting++
	}
})
if b, ok := w.Get[phys.Body3](crate); ok {
	b.Wake() // the player kicked it
}

Character controllers

CharacterController3 and CharacterController2 move a capsule under direct control rather than through the solver. Move sweeps the capsule along a velocity, slides it along whatever it meets, steps it up ledges no taller than StepHeight, refuses slopes steeper than MaxSlope, and reports Grounded and the GroundNormal. A controller updates only its transform and controller state; it does not apply impulses. Give it a trigger collider for overlap detection, or a solid collider with a kinematic body when rigid bodies should respond to it. The controller ignores its own collider. Gravity and jumping are the game's responsibility; Move only applies the supplied velocity.

hero := w.SpawnWith(gfx.At(1, 3.5, -8))
ctrl := phys.CharacterController3{Radius: 0.35, HalfHeight: 0.45,
	StepHeight: 0.45, MaxSlope: 50}

// Each update: walk, and fall until the sweep finds ground.
vel := lin.V3(2.5*dir, -6, 0)
ctrl.Move(w, hero, vel, float32(ctx.Delta))
if ctrl.Grounded && jump {
	// launch, then integrate the vertical velocity yourself
}

Tuning

Each update is split into substeps. In each substep, velocities integrate gravity and forces. A sweep over bounding boxes finds candidate pairs: the moving colliders are swept against each other and against a sorted list of the colliders that cannot move, which is kept between updates and rebuilt only when one of them changes, so a level of ten thousand static boxes costs little more than the bodies moving through it. The shapes generate contact points. A sequential impulse solver iterates over the contacts and joints, applying normal impulses with restitution, friction impulses clamped by the normal impulse, and a small positional correction. Positions then integrate. After the last substep of the update, a relax pass solves the contacts and joints once more with the positional correction dropped, which takes the separating speed that correction added back out of the velocities the update ends with, and bodies are then tested for sleep. Restitution is kept out of that correction, so bounces survive the relax pass.

Settings.Substeps (default 4) trades speed for stability under fast motion and tall stacks; Iterations (default 8) stiffens contacts and joints. Five hundred boxes step in a few milliseconds at the defaults. Keep the sizes and masses of interacting bodies within a factor of a hundred or so of each other, as with every impulse solver.

// 2D at pixel scale, with a stiffer solver than the defaults.
w.SetResource(phys.Settings2{Gravity: lin.V2(0, 900), Substeps: 6, Iterations: 12})

// Measure the cost of the step.
step := ctx.Profile("physics")
w.Update(ctx.Delta)
step.End()

Soft bodies

Rigid bodies keep their shape. For things that bend, squash and flow, the phys/soft package simulates particles held together by constraints: Cloth for sheets, SoftBody3 for a closed mesh that keeps its volume, and Fluid2 for liquid in the plane. All three are components stepped by one system, soft.System, on the same world as the rigid bodies. Register it after System3, so soft bodies see where the rigid ones ended the update.

w.SetResource(soft.Settings{Gravity3: lin.V3(0, -9.8, 0)})
w.AddSystem("physics", phys.System3)
w.AddSystem("soft", soft.System)

A zero Gravity3 takes the gravity from the phys.Settings3 resource, and a zero Gravity2 from phys.Settings2, so rigid and soft bodies fall together without saying it twice. Ground turns on a floor plane at GroundY for scenes with no collider under them.

Particles collide with the static and kinematic colliders already in the world, through the signed-distance queries above: spheres, boxes, capsules and compounds in 3D, and circles, boxes, polygons and capsules in 2D. They do not push rigid bodies back, and dynamic bodies are ignored. Triggers are also ignored. A soft component's mask checks collider layers; the collider's own mask is not consulted. There is no cloth self-collision or collision between separate soft components.

Cloth

NewCloth builds a rectangular sheet of particles: distance constraints along the edges, diagonals across each cell, and a bending constraint across each pair of edges in line. Pinned particles hang the sheet up, and Wind pushes each cell by the air blowing through it, so a sheet edge-on to the wind is barely moved.

pins := []int{0, 1, 2, 3} // the top-left corner, held
flag := w.SpawnWith(soft.NewCloth(soft.ClothSpec{
	Width: 26, Height: 16, Spacing: 0.14, Mass: 0.4,
	Origin: lin.V3(-2, 3.6, 0), Pinned: pins, Wind: lin.V3(0, 0, 5),
}))

Pin, Free and Move change what is held while the game runs, which is how a cape follows a running character. Positions and Velocities are the particles themselves.

A mesh that follows

Cloth and soft bodies are drawn by keeping a gfx.Mesh in step with their particles. NewMesh uploads a mesh shaped like the body, and UpdateMesh writes the positions and recomputes the normals each frame. Particle positions are world space, so the mesh is drawn with an identity matrix and needs no transform. Give cloth a DoubleSided material, because it is seen from both sides.

Construct each soft component independently. Their particle storage is private, so copying a component, cloning it through the ECS, or spawning it repeatedly from a prefab shares that storage. UpdateMesh changes the mesh; it does not submit a draw call.

c, _ := w.Get[soft.Cloth](flag)
mesh, err := c.NewMesh(ctx.Gfx) // graphics owns it; Destroy releases it early

// In Draw, after the world has stepped.
c.UpdateMesh(mesh)
ctx.Gfx.DrawMesh(mesh, gfx.Material{BaseColor: gfx.RGB(220, 60, 70), DoubleSided: true}, lin.Identity())

Volumetric soft bodies

NewSoftBody3 takes a closed triangle mesh, from gfx.CubeMesh, gfx.SphereMesh, gfx.TorusMesh or a loaded glTF model, welds the vertices that share a position into particles, and holds them with constraints along the surface edges, one constraint on the enclosed volume, and shape matching that pulls the body back toward its original shape rotated to where it is now. A body resting under gravity keeps its volume within a few percent.

cv, ci := gfx.CubeMesh()
jelly := w.SpawnWith(soft.NewSoftBody3(soft.SoftBody3Spec{
	Vertices: cv, Indices: ci, Scale: 1.4, Position: lin.V3(0, 2.4, 0),
	Mass: 3, Compliance: 0.001, ShapeMatch: 0.04,
}))

b, _ := w.Get[soft.SoftBody3](jelly)
b.AddImpulse(lin.V3(-9, 16, 0)) // kicked
b.Pressure = 1.3                // inflated

Fluids

Fluid2 is position-based fluids: a density constraint over a spatial hash keeps the liquid incompressible, a small push at close range stops it clumping, and viscosity pulls neighbours toward a shared velocity. Bounds is the tank, and the 2D colliders in the world are obstacles in it. The game draws the particles itself, from Positions, as sprites or circles.

f := soft.NewFluid2(soft.Fluid2Spec{Bounds: tank, Spacing: 7})
f.Fill(lin.Rect{X: tank.X + 8, Y: tank.Y + 8, W: tank.W/2, H: tank.H - 16})
w.SpawnWith(f)

// In Draw.
for i, p := range f.Positions() {
	shade := lin.Clamp(f.Density(i)/f.RestDensity(), 0, 1)
	ctx.Gfx.Draw(drop, gfx.Sprite{Pos: p, Size: lin.V2(16, 16), Color: water(shade)})
}

A fluid keeps its own Substeps, one by default, because its density solve is a whole-step pressure solve: splitting it finer leaves the same residual in a shorter step, and the velocity read back from that is noise. The Substeps in the world settings belongs to cloth and soft bodies.

Tuning the soft solver

Distance-constraint compliance is in metres per newton with SI units: zero is rigid and larger is softer. Volume compliance has different dimensions because its constraint measures volume. XPBD reduces timestep dependence, while finite solver convergence still depends on substeps and iterations. Settings.Substeps (default 4) and Iterations (default 4) trade time for stiffness; cloth is stiffer for the same work with more substeps and fewer iterations than the other way around. The solver reuses its scratch buffers; initial steps, particle growth and larger neighbourhoods can allocate.

Colliders are placed once an update, and each component measures its particles only against the colliders near the box around them, so colliders elsewhere in the level cost almost nothing. A cloth solves its links in twelve batches of links that share no particle. A fluid from about two thousand particles, a cloth from about sixteen thousand and a group of soft bodies with that many particles between them are split across goroutines, a batch or a pass at a time. The work is cut the same way on every machine and each piece writes only its own particles, so a scene steps to the same result whatever GOMAXPROCS is; below those sizes the solver stays on one goroutine and starts none.

The examples/softbody program puts all three together: a flag on a pole, a jelly cube beside a rigid crate, and a tank of fluid in the corner of the screen.

Seeing what the solver sees

phys.DrawColliders3 outlines every collider in a world over the 3D scene as debug lines and draws the normal of each contact the last update reported; DrawColliders2 does the same in 2D as stroked paths. Awake bodies, sleeping bodies and static colliders are told apart by colour, which DrawCollidersColors3 chooses. DrawShape3 and DrawShape2 outline one shape placed by a transform, for a query result or a shape the game is about to cast.

func (g *game) Draw(ctx *engine.Context) error {
	// ... the scene ...
	if g.showColliders {
		phys.DrawColliders3(ctx.Gfx, g.world)
	}
	return nil
}

The debug console has a switch for the same drawing, and counts the bodies, contacts and joints beside it.

Orbital mechanics

For spaceflight, planets and moons, use the orbit package. It works at astronomical scale in double precision and writes into the same transforms.