Bunyip a game engine in Go GitHub

Package github.com/matjam/bunyip/phys/soft

phys/soft

Package soft simulates soft bodies, cloth and fluids as particles on the entity component system. A Cloth is a sheet that hangs, folds and blows in the wind; a SoftBody3 is a closed mesh that squashes and springs back while keeping its volume; a Fluid2 is a body of liquid in the plane. All three are components stepped by one system.

w.SetResource(soft.Settings{Gravity3: lin.V3(0, -9.8, 0), Ground: true})
flag := w.SpawnWith(soft.NewCloth(soft.ClothSpec{
	Width: 24, Height: 16, Spacing: 0.1, Mass: 0.4,
	Origin: lin.V3(0, 3, 0), Pinned: []int{0, 24 * 15},
	Wind: lin.V3(6, 0, 1.5),
}))
w.AddSystem("soft", soft.System)

The solver is extended position-based dynamics. A cloth or soft body update is split into Settings.Substeps substeps. A substep predicts positions from the velocities, projects the constraints Settings.Iterations times, pushes particles out of the solids they ended up inside, and reads the new velocities back from how far each particle moved. Distance-constraint compliance is in metres per newton for SI units: zero is rigid and larger is softer. XPBD reduces timestep dependence, but finite iterations still affect convergence. A fluid keeps its own substep count, one by default, for the reason Fluid2Spec.Substeps gives.

A cloth's distance constraints are solved in twelve batches, each a set of links that share no particle (edges across and down, the two diagonals and the bends, split by row or column parity), in that order. A large cloth splits each batch across goroutines, a large fluid splits each of its passes, and many soft bodies are stepped on several goroutines at once. The pieces are cut the same way on every machine and each writes only its own particles, so the result is the same whatever GOMAXPROCS is, and the same as a small scene stepped on one goroutine.

Particle positions are world space. A cloth or a soft body carries no transform, and is drawn by keeping a gfx.Mesh in step with it:

cloth.UpdateMesh(mesh)                  // positions and normals
ctx.Gfx.DrawMesh(mesh, material, lin.Identity())

Particles collide with the static and kinematic phys colliders in the same world, placed once an update with phys.PlaceShape3 and phys.PlaceShape2, and with the ground plane Settings.Ground turns on. Each component measures its particles only against the colliders within reach of the box around them. The shapes that carry a signed distance are Sphere, Box3, Capsule and compounds of those in 3D, and Circle, Box2, Polygon2 and Capsule2 in 2D; other shapes, and the colliders of dynamic bodies, are ignored. Soft bodies do not push rigid bodies back. Triggers are ignored. Particle masks test the collider's Layer; its Mask is not used by the soft solver. There is no cloth self-collision or collision between separate soft components.

For rigid bodies, joints and queries, see the phys package. Both simulations run on the same world and the same colliders.

Index

Examples

Example
package main

import (
	"fmt"
	"math"

	"github.com/matjam/bunyip/ecs"
	"github.com/matjam/bunyip/gfx"
	"github.com/matjam/bunyip/lin"
	"github.com/matjam/bunyip/phys/soft"
)

func main() {
	w := ecs.NewWorld()
	w.SetResource(soft.Settings{Gravity3: lin.V3(0, -9.8, 0), Ground: true})
	w.AddSystem("soft", soft.System)

	// A ball of jelly dropped onto the ground plane.
	verts, indices := gfx.SphereMesh(12, 16)
	ball := w.SpawnWith(soft.NewSoftBody3(soft.SoftBody3Spec{
		Vertices: verts, Indices: indices, Scale: 0.5, Position: lin.V3(0, 3, 0), Mass: 2,
	}))
	for range 180 {
		w.Update(1.0 / 60)
	}
	b, _ := w.Get[soft.SoftBody3](ball)
	lowest := float32(math.Inf(1))
	for _, p := range b.Particles() {
		lowest = min(lowest, p.Y)
	}
	fmt.Printf("the jelly rests on the plane, lowest particle at y = %.2f, with %.0f%% of its volume\n",
		lowest, 100*b.Volume()/b.RestVolume())
}
Output
the jelly rests on the plane, lowest particle at y = 0.06, with 100% of its volume

Functions

System source

func System(w *ecs.World, dt float64)

System advances every Cloth, SoftBody3 and Fluid2 in the world by dt seconds. Register it after the phys system, so soft bodies see where the rigid ones ended the update. Nonpositive dt does nothing. Colliders are sampled once at entry and held fixed through the soft substeps.

Types

type Cloth source

type Cloth struct {
	// Wind is the air velocity the sheet feels, in units per second. The
	// force on each cell is its area times the speed of the wind through
	// it, so a sheet edge-on to the wind is barely pushed.
	Wind lin.Vec3
	// Damping removes this fraction of each particle's velocity per
	// second. Zero leaves the sheet undamped, which flutters for longer.
	Damping float32
	// Stretch, Shear and Bend are the compliances of the edge, diagonal
	// and bending constraints, in metres per newton. Zero is rigid;
	// larger is softer. Changing one takes effect on the next step.
	Stretch, Shear, Bend float32
	// Radius is how far a particle stays clear of a collider, and
	// Friction the Coulomb coefficient there, combined with the
	// collider's own as a geometric mean.
	Radius, Friction float32
	// Mask picks the collider layers the sheet collides with; zero means
	// all of them.
	Mask uint32
	// contains filtered or unexported fields
}

Cloth is a sheet of particles held together by distance constraints: a flag, a curtain, a cape, a sail. Build one with NewCloth and spawn it as a component; System steps it and UpdateMesh updates its render mesh. Draw that mesh separately with gfx.Graphics.DrawMesh. Positions are world space, so a cloth needs no transform. Copies of a Cloth share private particle storage; call NewCloth for each independent instance.

NewCloth source

func NewCloth(spec ClothSpec) Cloth

NewCloth builds a sheet from a spec. The particles start on a flat grid at rest, so a cloth pinned by two corners falls into its drape over the first few updates.

Example
package main

import (
	"fmt"

	"github.com/matjam/bunyip/ecs"
	"github.com/matjam/bunyip/lin"
	"github.com/matjam/bunyip/phys/soft"
)

func main() {
	w := ecs.NewWorld()
	w.SetResource(soft.Settings{Gravity3: lin.V3(0, -9.8, 0)})
	w.AddSystem("soft", soft.System)

	// A sheet hung from its two top corners.
	const cols, rows = 12, 10
	sheet := w.SpawnWith(soft.NewCloth(soft.ClothSpec{
		Width: cols, Height: rows, Spacing: 0.1, Mass: 0.5,
		Origin: lin.V3(0, 2, 0), Pinned: []int{0, cols - 1},
	}))
	for range 240 {
		w.Update(1.0 / 60)
	}
	c, _ := w.Get[soft.Cloth](sheet)
	pos := c.Positions()
	fmt.Printf("the bottom corner hangs %.1f below the pin\n", pos[0].Y-pos[c.Index(0, rows-1)].Y)
}
Output
the bottom corner hangs 0.9 below the pin

Free source

func (c *Cloth) Free(i int)

Free lets a pinned particle fall again.

Index source

func (c *Cloth) Index(x, y int) int

Index is the particle index of column x, row y.

Move source

func (c *Cloth) Move(i int, p lin.Vec3)

Move puts a particle at p without giving it velocity. Use it to carry a pinned corner: a flag on a moving pole, a cape on a running character.

NewMesh source

func (c *Cloth) NewMesh(g *gfx.Graphics) (*gfx.Mesh, error)

NewMesh uploads a mesh shaped like the sheet, one vertex per particle with UVs running across it. Call UpdateMesh each frame to follow the simulation, and Destroy the mesh when the game shuts down. A cloth is seen from both sides, so give it a material with DoubleSided set.

Pin source

func (c *Cloth) Pin(i int)

Pin holds a particle where it is, so the sheet hangs from it. A pinned particle ignores gravity, wind and every constraint.

Pinned source

func (c *Cloth) Pinned(i int) bool

Pinned reports whether a particle is held in place.

Positions source

func (c *Cloth) Positions() []lin.Vec3

Positions returns the particle positions in world space, row by row. The slice is the cloth's own: read it to draw the sheet, and move a particle with Move rather than writing to it.

Size source

func (c *Cloth) Size() (cols, rows int)

Size returns the particle counts across and down.

Translate source

func (c *Cloth) Translate(delta lin.Vec3)

Translate moves the whole sheet, pinned particles included.

UpdateMesh source

func (c *Cloth) UpdateMesh(m *gfx.Mesh) error

UpdateMesh writes the particle positions and fresh normals into a mesh built by NewMesh. Call it once a frame, after the world has stepped. The mesh keeps the cloth's own vertex slice, so a mesh built from one cloth must not be updated from another.

Velocities source

func (c *Cloth) Velocities() []lin.Vec3

Velocities returns the particle velocities, in the same order as Positions. The slice is the cloth's own.

type ClothSpec source

type ClothSpec struct {
	// Width and Height are how many particles the sheet has across and
	// down; zero means 16. Spacing is the distance between neighbours;
	// zero means 0.1.
	Width, Height int
	Spacing       float32
	// Mass is the mass of the whole sheet, shared equally; zero means 1.
	Mass float32
	// Origin is where particle 0 sits. Right and Down are the directions
	// the grid runs in; zero means +X across and -Y down, a sheet hanging
	// in the xy plane.
	Origin      lin.Vec3
	Right, Down lin.Vec3
	// Stretch is the compliance of the edges between neighbours, in
	// metres per newton; zero means 0, a sheet that does not stretch.
	// Shear is the compliance of the diagonals; zero means 0.
	Stretch, Shear float32
	// Bend is the compliance of the constraint across each pair of
	// edges in line, which resists folding; zero means 0.02, a sheet
	// that drapes. Pass a small positive value for stiffer cloth.
	Bend float32
	// Damping removes a fraction of each particle's velocity per second;
	// zero means 0.1.
	Damping float32
	// Pinned lists particle indices held in place. Index a particle with
	// y*Width+x, or with the Index method of the cloth once built.
	Pinned []int
	// Wind is the air velocity the sheet feels, in units per second.
	Wind lin.Vec3
	// Radius is how far a particle stays clear of a collider; zero means
	// a quarter of the spacing. Friction is the Coulomb coefficient
	// against colliders; zero means 0.4.
	Radius, Friction float32
	// Mask picks the collider layers the sheet collides with; zero means
	// all of them.
	Mask uint32
}

ClothSpec describes a rectangular sheet for NewCloth. Every zero field takes the default named in its comment, so ClothSpec{} is a valid sheet.

type Fluid2 source

type Fluid2 struct {
	// Bounds is the tank, in view units. Particles are kept inside it.
	// An empty rectangle leaves the fluid unbounded.
	Bounds lin.Rect
	// Substeps is how many solves the fluid takes per update, at least 1.
	Substeps int
	// Viscosity, SurfaceTension and Relaxation tune the solve, as
	// Fluid2Spec describes them. Damping removes this fraction of each
	// particle's velocity per second, and Friction is the Coulomb
	// coefficient against walls and colliders.
	Viscosity, SurfaceTension, Relaxation float32
	Damping, Friction                     float32
	// Mask picks the collider layers the fluid collides with; zero means
	// all of them.
	Mask uint32
	// contains filtered or unexported fields
}

Fluid2 is a body of liquid in the plane, simulated as particles: a tank of water, a wave, a splash of blood. Build one with NewFluid2, fill it with Fill or Add, and spawn it as a component; System steps it. The game draws the particles itself, from Positions, as sprites or circles. Positions are view units, with +Y down as the screen runs. Copies share private particle storage; use NewFluid2 for independent instances, including when constructing components for separate entities.

NewFluid2 source

func NewFluid2(spec Fluid2Spec) Fluid2

NewFluid2 builds an empty fluid. Fill it with Fill or Add.

Example
package main

import (
	"fmt"

	"github.com/matjam/bunyip/ecs"
	"github.com/matjam/bunyip/lin"
	"github.com/matjam/bunyip/phys/soft"
)

func main() {
	w := ecs.NewWorld()
	w.SetResource(soft.Settings{Gravity2: lin.V2(0, 900)})
	w.AddSystem("soft", soft.System)

	// A column of liquid released in a tank, in view units.
	f := soft.NewFluid2(soft.Fluid2Spec{Bounds: lin.Rect{W: 400, H: 300}, Spacing: 8})
	f.Fill(lin.Rect{X: 20, Y: 20, W: 120, H: 160})
	tank := w.SpawnWith(f)
	for range 240 {
		w.Update(1.0 / 60)
	}
	got, _ := w.Get[soft.Fluid2](tank)
	spread := float32(0)
	for _, p := range got.Positions() {
		spread = max(spread, p.X)
	}
	fmt.Printf("%d particles, spread to x = %.0f\n", got.Count(), spread)
}
Output
300 particles, spread to x = 396

Add source

func (f *Fluid2) Add(p lin.Vec2)

Add puts one particle at p, at rest.

Clear source

func (f *Fluid2) Clear()

Clear removes every particle.

Count source

func (f *Fluid2) Count() int

Count is how many particles the fluid has.

Density source

func (f *Fluid2) Density(i int) float32

Density is the density around particle i, as the last step measured it. Compare it with RestDensity to colour a splash by how packed it is, or to find the surface, where the density falls away.

Fill source

func (f *Fluid2) Fill(r lin.Rect)

Fill adds particles on a lattice of the fluid's spacing covering the rectangle, which is how a tank or a column of water starts. Every second row is offset by half a spacing, the packing a settled liquid finds anyway.

Positions source

func (f *Fluid2) Positions() []lin.Vec2

Positions returns the particle positions in view units. The slice is the fluid's own: read it to draw the liquid, and add particles with Add rather than writing to it.

Radius source

func (f *Fluid2) Radius() float32

Radius is how far a particle feels its neighbours.

RestDensity source

func (f *Fluid2) RestDensity() float32

RestDensity is the density the solver holds the fluid at.

Spacing source

func (f *Fluid2) Spacing() float32

Spacing is the distance between particles at rest.

Velocities source

func (f *Fluid2) Velocities() []lin.Vec2

Velocities returns the particle velocities, in the same order as Positions. The slice is the fluid's own.

type Fluid2Spec source

type Fluid2Spec struct {
	// Bounds is the tank the liquid is kept in, in view units. An empty
	// rectangle leaves it unbounded, held only by the colliders it meets.
	Bounds lin.Rect
	// Spacing is how far apart the particles sit at rest; zero means 8.
	// It sets the density the fluid settles at, so a change to it is a
	// change to the fluid.
	Spacing float32
	// Radius is how far a particle feels its neighbours; zero means two
	// and a half times the spacing, which gives each particle about
	// twenty neighbours. Larger is smoother and slower.
	Radius float32
	// Substeps is how many solves the fluid takes per update; zero means
	// 1, which is what position-based fluids are meant to run at. Raise
	// it for a fast, thin fluid that must not step past a wall, and
	// expect more jitter in return. The Substeps in the world Settings
	// belongs to cloth and soft bodies and does not reach a fluid.
	Substeps int
	// Viscosity is how fast a particle is pulled toward the velocity of
	// its neighbours, as a rate per second: 0 leaves the fluid free to
	// churn, 15 is water that settles, 60 is syrup. Zero means 15.
	Viscosity float32
	// SurfaceTension is the strength of the small push particles give
	// each other at close range, which stops them clumping into strings
	// and rounds off drops; zero means 0.1.
	SurfaceTension float32
	// Relaxation softens the density solve, so a particle with few
	// neighbours is not thrown across the tank; zero means 0.05.
	Relaxation float32
	// Damping removes a fraction of each particle's velocity per second;
	// zero means 0.1.
	Damping float32
	// Friction is the Coulomb coefficient against the tank walls and the
	// colliders; zero means 0.1, wet and slippery.
	Friction float32
	// Mask picks the collider layers the fluid collides with; zero means
	// all of them.
	Mask uint32
}

Fluid2Spec describes a body of liquid for NewFluid2. Every zero field takes the default named in its comment.

type Settings source

type Settings struct {
	// Gravity3 is the acceleration on cloth and soft bodies. Zero takes
	// the gravity from the phys.Settings3 resource when the world has
	// one, so rigid and soft bodies fall together.
	Gravity3 lin.Vec3
	// Gravity2 is the acceleration on fluids, in view units per second
	// squared, positive downward. Zero takes the gravity from the
	// phys.Settings2 resource when the world has one.
	Gravity2 lin.Vec2
	// Substeps is how many times the solver runs per update; zero means
	// 4. Iterations is how many times it projects the constraints per
	// substep; zero means 4. Raise either for stiffer cloth and firmer
	// jelly, at a cost in time.
	Substeps, Iterations int
	// Ground turns on a floor plane at height GroundY that every particle
	// rests on, for scenes with no collider under them. GroundFriction is
	// the Coulomb coefficient there; zero means 0.5.
	Ground         bool
	GroundY        float32
	GroundFriction float32
}

Settings is the world resource that tunes the soft solver. Zero substeps mean 4 and zero iterations 4.

type SoftBody3 source

type SoftBody3 struct {
	// Compliance is the give in the surface constraints and
	// VolumeCompliance the give in the volume constraint. Surface compliance
	// is metres per newton with SI units; volume compliance has different
	// dimensions because the constrained quantity is volume. Zero is rigid;
	// larger is softer.
	Compliance, VolumeCompliance float32
	// Pressure is the volume the body aims for as a multiple of its rest
	// volume. Raise it above 1 to inflate the body, lower it to deflate.
	Pressure float32
	// ShapeMatch is how strongly the body returns to its original shape
	// each substep, from 0 (never) to 1 (at once).
	ShapeMatch float32
	// Damping removes this fraction of each particle's velocity per
	// second.
	Damping float32
	// Radius is how far a particle stays clear of a collider, and
	// Friction the Coulomb coefficient there, combined with the
	// collider's own as a geometric mean.
	Radius, Friction float32
	// Mask picks the collider layers the body collides with; zero means
	// all of them.
	Mask uint32
	// contains filtered or unexported fields
}

SoftBody3 is a closed mesh simulated as a skin of particles that keeps its volume: a jelly cube, a beach ball, a lump of dough. Build one with NewSoftBody3 and spawn it as a component; System steps it and UpdateMesh updates its render mesh, which the game draws separately. Positions are world space, so a soft body needs no transform. Copies share private particle storage; construct each independent instance.

NewSoftBody3 source

func NewSoftBody3(spec SoftBody3Spec) SoftBody3

NewSoftBody3 builds a soft body from a closed mesh. Vertices closer than a thousandth of the mesh's size become one particle, the edges of the triangles become distance constraints, and the triangles together give the volume the body holds on to.

AddImpulse source

func (b *SoftBody3) AddImpulse(impulse lin.Vec3)

AddImpulse changes the whole body's velocity by impulse divided by its mass, as a hit or a kick does. To push one part of it, add to the velocities of the particles there instead.

Center source

func (b *SoftBody3) Center() lin.Vec3

Center is the average of the particle positions, which is the body's centre of mass because every particle weighs the same.

Mass source

func (b *SoftBody3) Mass() float32

Mass is the mass of the whole body.

NewMesh source

func (b *SoftBody3) NewMesh(g *gfx.Graphics) (*gfx.Mesh, error)

NewMesh uploads the body's mesh. Call UpdateMesh each frame to follow the simulation, and Destroy the mesh when the game shuts down.

Particles source

func (b *SoftBody3) Particles() []lin.Vec3

Particles returns the particle positions in world space. The slice is the body's own; move the body with Translate rather than writing to it.

RestVolume source

func (b *SoftBody3) RestVolume() float32

RestVolume is the volume the mesh enclosed when the body was built.

Translate source

func (b *SoftBody3) Translate(delta lin.Vec3)

Translate moves the whole body without changing its shape or velocity.

UpdateMesh source

func (b *SoftBody3) UpdateMesh(m *gfx.Mesh) error

UpdateMesh writes the particle positions and fresh normals into a mesh built by NewMesh. Call it once a frame, after the world has stepped. The mesh keeps the body's own vertex slice, so a mesh built from one body must not be updated from another.

Velocities source

func (b *SoftBody3) Velocities() []lin.Vec3

Velocities returns the particle velocities, in the same order as Particles. The slice is the body's own.

Volume source

func (b *SoftBody3) Volume() float32

Volume is the volume the mesh encloses now. A body resting under gravity keeps it within a few percent of RestVolume times Pressure.

type SoftBody3Spec source

type SoftBody3Spec struct {
	// Vertices and Indices are a closed triangle mesh, as gfx.CubeMesh,
	// gfx.SphereMesh and gfx.TorusMesh return them, or as a glTF model
	// gives them. Vertices that share a position become one particle, so
	// a mesh split for flat shading or for UV seams still simulates as
	// one skin.
	Vertices []gfx.Vertex
	Indices  []uint32
	// Position moves the mesh into the world, and Scale sizes it; zero
	// scale means 1.
	Position lin.Vec3
	Scale    float32
	// Mass is the mass of the whole body, shared equally between the
	// particles; zero means 1.
	Mass float32
	// Compliance is the give in the constraints along the surface edges,
	// in metres per newton; zero means 0.0005, a firm jelly. Larger is
	// softer. To request rigid surface constraints, set the returned body's
	// Compliance to zero after construction.
	Compliance float32
	// VolumeCompliance is the give in the constraint that holds the
	// enclosed volume; zero means 0, which holds the volume as hard as
	// the solver can. Pressure is the volume the body aims for as a
	// multiple of the volume it was built with; zero means 1, and above
	// 1 inflates it.
	VolumeCompliance, Pressure float32
	// ShapeMatch pulls the body back toward its original shape, rotated
	// to where it is now: 0 leaves it a bag of constraints, 1 makes it
	// nearly rigid. Zero means 0.1.
	ShapeMatch float32
	// Damping removes a fraction of each particle's velocity per second;
	// zero means 0.5, which settles a dropped body quickly.
	Damping float32
	// Radius is how far a particle stays clear of a collider; zero means
	// a fiftieth of the mesh's size. Friction is the Coulomb coefficient
	// against colliders; zero means 0.5.
	Radius, Friction float32
	// Mask picks the collider layers the body collides with; zero means
	// all of them.
	Mask uint32
}

SoftBody3Spec describes a volumetric soft body for NewSoftBody3. Every zero field takes the default named in its comment.

Source files

batch_test.go bench_test.go body3.go cloth.go example_test.go fluid2.go parallel.go parallel_test.go scaling_bench_test.go soft.go soft_test.go