Example examples/softbody
Soft bodies

This program runs all three deformable simulations of phys/soft in one scene. A flag of cloth hangs from a pole and flaps in a gust that swings around. A jelly cube falls onto the floor, squashes and springs back, and can be kicked into the air. A tank of two-dimensional fluid sits in the corner of the screen, breaking around a post. A rigid crate from phys drops beside the jelly, so the two simulations can be compared side by side.
Soft bodies are particles held together by constraints, solved by
extended position-based dynamics. Cloth, SoftBody3 and Fluid2 are
components on the same world as the rigid bodies, and
one system, soft.System, steps all three. Particles collide with the
static and kinematic colliders already in the world, through the
signed-distance queries in phys, and never push a rigid body back:
here the floor, the pole and the ball are colliders that both solvers
see. Read
the physics guide for the model and the
tuning, and the physics3d walkthrough for the rigid
half on its own.
Particle positions are world space, which shapes the drawing: a cloth or
a soft body carries no transform, and is drawn by keeping a gfx.Mesh
in step with its particles and drawing that mesh with an identity
matrix. The fluid has no mesh at all; the program draws one sprite per
particle.
Run it:
go run ./examples/softbody -seconds 3 -shot out.png
The flags are -seconds N and -shot file.png. Dragging orbits the
camera, the wheel zooms, Space kicks the jelly, R drops everything again
and Escape quits.
Package and constants
The flag's size is in particles rather than in metres: 26 across and 16
down, 0.14 metres apart. crate is a marker component with no fields,
so the one rigid body in the scene can be found by a query instead of
being kept as a field and cleared by hand.
// Command softbody shows the phys/soft package: a flag of cloth
// flapping on a pole, a jelly cube dropping onto the floor beside a
// rigid crate, and a tank of two-dimensional fluid in the corner of the
// screen. Drag to orbit, scroll to zoom, Space kicks the jelly, R drops
// everything again, Escape quits.
package main
import (
"flag"
"fmt"
"image"
"image/color"
"math"
"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/particle"
"github.com/matjam/bunyip/phys"
"github.com/matjam/bunyip/phys/soft"
"github.com/matjam/bunyip/ui"
)
// The flag, in particles across and down, and how far apart they sit.
const (
flagCols = 26
flagRows = 16
flagSpacing = 0.14
)
// crate marks the one rigid body in the scene, so it can be drawn from a
// query rather than kept as a field.
type crate struct{}
The game type
The game holds four meshes, three textures, and the entities of the
cloth, the soft body and the fluid. flag and jelly are the meshes
that follow the two deformables; they are rebuilt whenever the bodies
are, because a mesh is shaped for the particle count it was made from.
type game struct {
seconds float64
shot string
font *gfx.Font
ui *ui.Context
world *ecs.World
cube *gfx.Mesh
ball *gfx.Mesh
flag *gfx.Mesh
jelly *gfx.Mesh
white *gfx.Texture
drop *gfx.Texture
disc *gfx.Texture
cloth ecs.Entity
body ecs.Entity
fluid ecs.Entity
crates *ecs.Query2[gfx.Transform, crate]
tank lin.Rect
yaw float32
pitch float32
dist float32
lastX float32
lastY float32
dragging bool
shotDone bool
kicked bool
}
Init: the world, the colliders and the tank
Two resources tune the two solvers. phys.Settings3 gives the rigid
bodies gravity, and soft.Settings gives the soft ones their substep
count. Gravity3 is left zero on purpose: a zero takes the gravity from
phys.Settings3, so rigid and soft bodies fall together without the
number appearing twice. Gravity2 has to be set, because the fluid
lives in view units on the screen rather than in metres in the world.
The floor, the pole and the ball are colliders with no body, which is
what makes them static. Both systems see them: phys.System3 collides
rigid bodies against them and soft.System pushes particles out of
them. The two systems are registered in that order, so the soft solver
sees where the rigid bodies ended the update.
soft.NewFluid2 takes a tank rectangle and the spacing between
particles at rest; Fill seeds a region of it with liquid, here the
left half, so it collapses into the rest of the tank as soon as the
simulation starts. The post in the tank is an ordinary
phys.Collider2 on a gfx.Transform2, which is how a 2D obstacle
reaches the fluid.
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.cube, err = ctx.Gfx.NewMesh(cv, ci); err != nil {
return err
}
sv, si := gfx.SphereMesh(16, 24)
if g.ball, err = ctx.Gfx.NewMesh(sv, si); err != nil {
return err
}
if g.drop, err = ctx.Gfx.NewTexture(particle.SoftCircle(32), gfx.TextureOptions{Linear: true}); err != nil {
return err
}
pixel := image.NewNRGBA(image.Rect(0, 0, 1, 1))
pixel.Set(0, 0, color.NRGBA{R: 255, G: 255, B: 255, A: 255})
if g.white, err = ctx.Gfx.NewTexture(pixel, gfx.TextureOptions{}); err != nil {
return err
}
if g.disc, err = ctx.Gfx.NewTexture(discImage(64), gfx.TextureOptions{Linear: true}); err != nil {
return err
}
g.yaw, g.pitch, g.dist = 0.9, 0.35, 12
w := ecs.NewWorld()
g.world = w
g.crates = w.Query2[gfx.Transform, crate]()
w.SetResource(phys.Settings3{Gravity: lin.V3(0, -9.8, 0)})
// The soft solver takes its 3D gravity from the physics settings; the
// fluid needs its own, in view units per second squared.
w.SetResource(soft.Settings{Gravity2: lin.V2(0, 900), Substeps: 6})
// The floor and the pole are static colliders, which both solvers see.
w.SpawnWith(gfx.Transform{Position: lin.V3(0, -0.5, 0)}, phys.Collider3{Shape: phys.Box3{Half: lin.V3(12, 0.5, 12)}})
w.SpawnWith(gfx.Transform{Position: lin.V3(-2.2, 2, 0)}, phys.Collider3{Shape: phys.Box3{Half: lin.V3(0.08, 2, 0.08)}})
// A ball for the cloth to fall past on its way out.
w.SpawnWith(gfx.Transform{Position: lin.V3(0.9, 0.75, 0.9)}, phys.Collider3{Shape: phys.Sphere{Radius: 0.75}})
w.AddSystem("physics", phys.System3)
w.AddSystem("soft", soft.System)
g.tank = lin.Rect{X: 24, Y: 24, W: 280, H: 300}
fluid := soft.NewFluid2(soft.Fluid2Spec{Bounds: g.tank, Spacing: 7})
fluid.Fill(lin.Rect{X: g.tank.X + 8, Y: g.tank.Y + 8, W: g.tank.W/2 - 8, H: g.tank.H - 16})
g.fluid = w.SpawnWith(fluid)
// A post in the tank for the liquid to break around.
w.SpawnWith(gfx.Transform2{Position: g.tank.Center()}, phys.Collider2{Shape: phys.Circle{Radius: 34}})
if err := g.reset(ctx); err != nil {
return err
}
return nil
}
reset: building the cloth and the jelly
reset is called from Init and again on R, so everything it builds it
first tears down: the two soft entities, every entity carrying the
crate marker, and the two meshes.
soft.NewCloth builds a rectangular sheet: distance constraints along
the edges and diagonals, and a bending constraint across each pair of
edges in line. Pinned is the list of particle indices that are held,
computed here as the first particle of every row, which hangs the sheet
by its left edge. Bend is compliance in metres per newton, so zero is
rigid and larger is softer. XPBD accounts for the timestep in compliance,
though substeps and solver iterations still affect the result. Wind
pushes each cell by the air blowing through it, so a
sheet edge-on to the wind is barely moved.
soft.NewSoftBody3 takes a closed triangle mesh, here the built-in
cube, welds the vertices that share a position into particles, and holds
them with edge constraints, one constraint on the enclosed volume and
ShapeMatch, which pulls the body back towards its original shape
rotated to where it is now. The crate beside it is a plain
phys.Dynamic3 body with a box collider and the crate marker.
c.NewMesh(ctx.Gfx) uploads a mesh shaped like the cloth, and
b.NewMesh one shaped like the body. They are GPU resources like any
other, so the old pair is destroyed before the new one is made.
// reset builds the flag and the jelly cube again, and drops the crate.
func (g *game) reset(ctx *engine.Context) error {
w := g.world
for _, e := range []ecs.Entity{g.cloth, g.body} {
if e != ecs.None {
w.Despawn(e)
}
}
g.crates.Each(func(e ecs.Entity, _ *gfx.Transform, _ *crate) { w.Despawn(e) })
g.kicked = false
pinned := make([]int, 0, flagRows)
for y := range flagRows {
pinned = append(pinned, y*flagCols)
}
cloth := soft.NewCloth(soft.ClothSpec{
Width: flagCols, Height: flagRows, Spacing: flagSpacing, Mass: 0.4,
Origin: lin.V3(-2.1, 3.6, 0), Pinned: pinned,
Bend: 0.05, Damping: 0.4, Wind: lin.V3(0, 0, 5),
})
g.cloth = w.SpawnWith(cloth)
cv, ci := gfx.CubeMesh()
body := soft.NewSoftBody3(soft.SoftBody3Spec{
Vertices: cv, Indices: ci, Scale: 1.4, Position: lin.V3(1.6, 2.4, -1.4), Mass: 3,
Compliance: 0.001, ShapeMatch: 0.04, Damping: 0.6,
})
g.body = w.SpawnWith(body)
rigid := phys.Dynamic3(2)
rigid.Friction, rigid.Restitution = 0.6, 0.1
w.SpawnWith(gfx.Transform{Position: lin.V3(3.6, 2.4, -1.4)}, rigid,
phys.Collider3{Shape: phys.Box3{Half: lin.V3(0.7, 0.7, 0.7)}}, crate{})
// The meshes follow the new cloth and body.
if g.flag != nil {
g.flag.Destroy()
g.jelly.Destroy()
}
var err error
c, _ := w.Get[soft.Cloth](g.cloth)
if g.flag, err = c.NewMesh(ctx.Gfx); err != nil {
return err
}
b, _ := w.Get[soft.SoftBody3](g.body)
if g.jelly, err = b.NewMesh(ctx.Gfx); err != nil {
return err
}
return nil
}
func (g *game) Shutdown(ctx *engine.Context) {
g.cube.Destroy()
g.ball.Destroy()
g.flag.Destroy()
g.jelly.Destroy()
g.white.Destroy()
g.drop.Destroy()
g.disc.Destroy()
g.font.Destroy()
}
Update: the gust, the kick and the step
ecs.World.Get returns a pointer to the component in its table, so assigning
to c.Wind changes the value the solver will read. The gust is three
sines at different rates, which keeps the flag flapping rather than
standing out stiffly in a steady wind.
b.AddImpulse throws the whole body, which is how a soft body is pushed
without reaching for its particles. A timed run kicks it automatically
at three tenths of the run, so the screenshot catches the jelly in the
air.
g.world.Update(ctx.Delta) runs both systems at the fixed step, wrapped
in a profile scope named soft whose time the panel prints and the F3
overlay reports.
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.KeyPressed(input.KeyR) {
if err := g.reset(ctx); err != nil {
return err
}
}
// A gust that swings around, so the flag flaps rather than sitting out
// stiffly in a steady wind.
if c, ok := g.world.Get[soft.Cloth](g.cloth); ok {
t := float32(ctx.Time)
c.Wind = lin.V3(1.5*sin(t*1.7), 0.6*sin(t*2.3), 5+2.5*sin(t*1.1))
}
b, hasBody := g.world.Get[soft.SoftBody3](g.body)
if hasBody && (in.KeyPressed(input.KeySpace) || (g.seconds > 0 && !g.kicked && ctx.Time >= g.seconds*0.3)) {
b.AddImpulse(lin.V3(-9, 16, 0))
g.kicked = true
}
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.4)
}
g.lastX, g.lastY = x, y
_, dy := in.Scroll()
g.dist = lin.Clamp(g.dist-float32(dy)*0.8, 4, 40)
step := ctx.Profile("soft")
g.world.Update(ctx.Delta)
step.End()
if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
ctx.Screenshot(g.shot)
g.shotDone = true
}
return nil
}
sin is a float32 convenience for the gust.
func sin(v float32) float32 { return float32(math.Sin(float64(v))) }
discImage draws the post in the tank: white, with the alpha falling
off over the last unit of the radius, which is a cheap antialiased edge.
// discImage is a filled circle with a soft edge, for the post in the
// tank.
func discImage(size int) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, size, size))
r := float64(size) / 2
for y := range size {
for x := range size {
dx, dy := float64(x)+0.5-r, float64(y)+0.5-r
d := math.Sqrt(dx*dx + dy*dy)
a := math.Max(0, math.Min(1, r-d))
img.Set(x, y, color.NRGBA{R: 255, G: 255, B: 255, A: uint8(a * 255)})
}
}
return img
}
Draw: the rigid scene
The camera, the light and the static geometry come first. The floor, the pole and the ball are drawn as scaled meshes at the same places their colliders were spawned; the drawing and the simulation are separate values that happen to line up, which is worth remembering when one is changed. The crate is drawn from the query, so the transform the simulation wrote is the one drawn.
func (g *game) Draw(ctx *engine.Context) error {
gr := ctx.Gfx
w := g.world
gr.SetCamera(gfx.OrbitCamera(lin.V3(0.6, 1.6, 0), g.yaw, g.pitch, g.dist))
gr.SetLight(gfx.Light{
Direction: lin.V3(-0.6, -1, -0.4), Color: gfx.Color{R: 2.6, G: 2.5, B: 2.3, A: 1},
Sky: gfx.Sky{Zenith: gfx.Color{R: 0.32, G: 0.38, B: 0.55, A: 1}, Ground: gfx.Color{R: 0.16, G: 0.13, B: 0.11, A: 1}},
Shadows: true, ShadowDistance: 30,
})
// Floor, pole and the ball the cloth blows past.
gr.DrawMesh(g.cube, gfx.Material{BaseColor: gfx.RGB(120, 124, 132), Roughness: 0.95},
lin.Translate(lin.V3(0, -0.5, 0)).Mul(lin.Scale(lin.V3(24, 1, 24))))
gr.DrawMesh(g.cube, gfx.Material{BaseColor: gfx.RGB(90, 80, 70), Roughness: 0.6},
lin.Translate(lin.V3(-2.2, 2, 0)).Mul(lin.Scale(lin.V3(0.16, 4, 0.16))))
gr.DrawMesh(g.ball, gfx.Material{BaseColor: gfx.RGB(200, 200, 210), Roughness: 0.3, Metallic: 0.6},
lin.Translate(lin.V3(0.9, 0.75, 0.9)).Mul(lin.Scale(lin.V3(0.75, 0.75, 0.75))))
g.crates.Each(func(_ ecs.Entity, t *gfx.Transform, _ *crate) {
gr.DrawMeshAt(g.cube, gfx.Material{BaseColor: gfx.RGB(190, 140, 70), Roughness: 0.8},
gfx.Transform{Position: t.Position, Rotation: t.Rotation, Scale: lin.V3(1.4, 1.4, 1.4)})
})
Draw: the deformables
Each frame UpdateMesh writes the particle positions into the mesh and
recomputes its normals. Because the particles are in world space, both
meshes are drawn with lin.Identity() rather than a transform. The
cloth's material is DoubleSided, because a sheet is seen from both
sides and its back faces would otherwise be culled. The jelly gets a
clearcoat, which reads as a wet surface.
Both calls are guarded by ecs.World.Get, so drawing only proceeds when the
expected component exists. R rebuilds the entities synchronously in
Update; Draw does not run halfway through that reset.
// The cloth and the jelly follow their particles. Both are drawn with
// an identity matrix, because their particles are already in world
// space, and the cloth is seen from both sides.
if c, ok := w.Get[soft.Cloth](g.cloth); ok {
if err := c.UpdateMesh(g.flag); err != nil {
return err
}
gr.DrawMesh(g.flag, gfx.Material{BaseColor: gfx.RGB(220, 60, 70), Roughness: 0.85, DoubleSided: true}, lin.Identity())
}
if b, ok := w.Get[soft.SoftBody3](g.body); ok {
if err := b.UpdateMesh(g.jelly); err != nil {
return err
}
gr.DrawMesh(g.jelly, gfx.Material{BaseColor: gfx.RGB(80, 210, 140), Roughness: 0.25, Clearcoat: 1, ClearcoatRoughness: 0.1}, lin.Identity())
}
g.drawTank(ctx)
Draw: the panel
The panel reports the profile scope's time and the live particle count,
and its button calls the same AddImpulse that Space does. u.Button
returns true on the frame it is clicked, which is the immediate-mode
form.
u := g.ui
u.Begin(ctx.Input, func() {
u.Panel("Soft bodies", ui.Rect{X: 12, Y: ctx.Height - 132, W: 380, H: 120}, func() {
ms := 0.0
if len(ctx.Stats.Scopes) > 0 {
ms = ctx.Stats.Scopes[0].MS
}
n := 0
if f, ok := g.world.Get[soft.Fluid2](g.fluid); ok {
n = f.Count()
}
u.Label(fmt.Sprintf("soft %.2f ms/frame: a %dx%d cloth flag, a jelly cube and %d fluid particles",
ms, flagCols, flagRows, n))
if u.Button("Kick the jelly (Space)") {
if b, ok := g.world.Get[soft.SoftBody3](g.body); ok {
b.AddImpulse(lin.V3(-9, 16, 0))
}
}
})
})
return nil
}
Drawing the fluid
The fluid has no mesh: the game draws its particles itself. Positions
returns them, Density(i) and RestDensity say how packed each one is,
and the colour is mixed from that, so the compressed body of the liquid
is deep blue and the spray at the surface is pale.
The layers are what keeps the tank readable over the 3D scene.
SetLayer sets the sort key for the 2D calls that follow: 10 for the
tank's dark background, 11 for the particles, 12 for the post over them,
and back to 0 at the end, because the layer is graphics state rather
than a per-call argument. Sprites are drawn in view units with the
origin at the top left, which is the same space the tank rectangle and
the fluid's own coordinates are in.
// drawTank draws the two-dimensional fluid over the scene: the tank, the
// post in it, and one soft circle per particle.
func (g *game) drawTank(ctx *engine.Context) {
gr := ctx.Gfx
f, ok := g.world.Get[soft.Fluid2](g.fluid)
if !ok {
return
}
gr.SetLayer(10)
gr.Draw(g.white, gfx.Sprite{Pos: g.tank.Min(), Size: g.tank.Size(), Color: gfx.Color{R: 0.03, G: 0.05, B: 0.09, A: 0.85}})
gr.SetLayer(11)
size := f.Spacing() * 2.4
half := lin.V2(size/2, size/2)
for i, p := range f.Positions() {
// Denser liquid is deeper blue; the spray at the surface is paler.
d := lin.Clamp(f.Density(i)/f.RestDensity(), 0, 1)
c := gfx.Color{R: 0.35 - 0.25*d, G: 0.6 - 0.2*d, B: 1, A: 0.9}
gr.Draw(g.drop, gfx.Sprite{Pos: p.Sub(half), Size: lin.V2(size, size), Color: c})
}
gr.SetLayer(12)
post := g.tank.Center()
gr.Draw(g.disc, gfx.Sprite{Pos: post.Sub(lin.V2(34, 34)), Size: lin.V2(68, 68), Color: gfx.Color{R: 0.5, G: 0.5, B: 0.55, A: 1}})
gr.SetLayer(0)
}
main
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 soft bodies", Width: 1100, Height: 700, Resizable: true},
&game{seconds: *seconds, shot: *shot})
if err != nil {
fmt.Fprintln(os.Stderr, "softbody:", err)
os.Exit(1)
}
}
What to try
- Set
Pressureabove one on the body inresetand watch the jelly inflate. - Raise the flag's
Bendcompliance inresetand see the sheet go limp, then set it to zero and see it go stiff. - Give
soft.SettingsmoreSubstepsinInitand watch the cloth stiffen and the profile scope in the panel grow. - Add a second
phys.Collider2to the tank inInitand watch the liquid break around both posts. - Pin the flag's far corner as well in
reset, by addingflagCols-1topinned, and see the sheet stretch between two points.