405 lines
10 KiB
Go
405 lines
10 KiB
Go
package physics_test
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"aego/core/log"
|
|
"aego/core/mathx"
|
|
"aego/engine"
|
|
"aego/engine/module"
|
|
"aego/modules/physics"
|
|
"aego/modules/physics/components"
|
|
"aego/modules/physics/systems"
|
|
"aego/modules/physics/world"
|
|
"aego/scene"
|
|
"aego/scene/sceneio"
|
|
"aego/schema"
|
|
)
|
|
|
|
const dt = float32(1.0 / 60)
|
|
|
|
func allLayers() [32]uint32 {
|
|
var m [32]uint32
|
|
for i := range m {
|
|
m[i] = ^uint32(0)
|
|
}
|
|
return m
|
|
}
|
|
|
|
type fixture struct {
|
|
t *testing.T
|
|
s *scene.Scene
|
|
sim *systems.Sim
|
|
}
|
|
|
|
func newTypes(t *testing.T) *scene.Types {
|
|
t.Helper()
|
|
types := scene.NewTypes(schema.NewRegistry())
|
|
if _, err := module.Load(types, nil, physics.New()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sceneio.RegisterUnknown(types); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := types.Schema().Freeze(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return types
|
|
}
|
|
|
|
func newFixture(t *testing.T) *fixture {
|
|
t.Helper()
|
|
s := scene.New(newTypes(t), nil, scene.Options{})
|
|
sim, err := systems.New(s, world.DefaultConfig(), allLayers(), log.Nop())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(sim.Close)
|
|
return &fixture{t: t, s: s, sim: sim}
|
|
}
|
|
|
|
func (f *fixture) node(parent scene.NodeHandle, name string, at mathx.Vec2) scene.NodeHandle {
|
|
if parent.IsZero() {
|
|
parent = f.s.Root()
|
|
}
|
|
h := f.s.Create(parent, name)
|
|
f.s.SetPosition(h, at)
|
|
return h
|
|
}
|
|
|
|
func (f *fixture) body(h scene.NodeHandle, typ components.BodyType) *components.Body2D {
|
|
b := scene.Add[components.Body2D](f.s, h)
|
|
*b = components.DefaultBody2D()
|
|
b.Type = typ
|
|
return b
|
|
}
|
|
|
|
func (f *fixture) box(h scene.NodeHandle, size mathx.Vec2) {
|
|
c := scene.Add[components.BoxCollider2D](f.s, h)
|
|
*c = components.DefaultBox()
|
|
c.Size = size
|
|
}
|
|
|
|
func (f *fixture) ground() scene.NodeHandle {
|
|
g := f.node(scene.NodeHandle{}, "Ground", mathx.Vec2{Y: 5})
|
|
f.box(g, mathx.Vec2{X: 20, Y: 1})
|
|
return g
|
|
}
|
|
|
|
func (f *fixture) crate(at mathx.Vec2) (scene.NodeHandle, *components.Body2D) {
|
|
h := f.node(scene.NodeHandle{}, "Crate", at)
|
|
b := f.body(h, components.Dynamic)
|
|
f.box(h, mathx.Vec2{X: 1, Y: 1})
|
|
return h, b
|
|
}
|
|
|
|
func (f *fixture) step(n int) {
|
|
for i := 0; i < n; i++ {
|
|
f.s.Flush()
|
|
f.sim.Sync()
|
|
f.sim.Step(dt)
|
|
f.sim.Writeback()
|
|
}
|
|
}
|
|
|
|
func (f *fixture) handle(h scene.NodeHandle) world.BodyHandle {
|
|
f.t.Helper()
|
|
bh, ok := f.sim.BodyOf(h)
|
|
if !ok {
|
|
f.t.Fatalf("node %s has no body", f.s.Name(h))
|
|
}
|
|
return bh
|
|
}
|
|
|
|
func near(a, b, eps float32) bool {
|
|
return mathx.Abs(a-b) <= eps
|
|
}
|
|
|
|
func TestBodyRestsOnImplicitStatic(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.ground()
|
|
b, _ := f.crate(mathx.Vec2{})
|
|
f.step(240)
|
|
if p := f.s.Local(b).Position; !near(p.Y, 4, 0.05) || !near(p.X, 0, 0.01) {
|
|
t.Fatalf("crate at %v", p)
|
|
}
|
|
c, _ := scene.Get[components.Body2D](f.s, b)
|
|
if mathx.Abs(c.Velocity.Y) > 0.05 {
|
|
t.Fatalf("velocity %v", c.Velocity)
|
|
}
|
|
if st := f.sim.Stats(); st.Bodies != 2 {
|
|
t.Fatalf("bodies %d", st.Bodies)
|
|
}
|
|
gt, _ := f.sim.World().BodyType(f.handle(f.s.Find(f.s.Root(), "Ground")))
|
|
if gt != world.Static {
|
|
t.Fatalf("ground body is %v", gt)
|
|
}
|
|
}
|
|
|
|
func TestCompoundMassMatchesWorld(t *testing.T) {
|
|
f := newFixture(t)
|
|
p := f.node(scene.NodeHandle{}, "Body", mathx.Vec2{})
|
|
f.body(p, components.Dynamic)
|
|
f.box(f.node(p, "A", mathx.Vec2{X: 1}), mathx.Vec2{X: 1, Y: 1})
|
|
f.box(f.node(p, "B", mathx.Vec2{X: -1}), mathx.Vec2{X: 1, Y: 2})
|
|
c := scene.Add[components.CircleCollider2D](f.s, f.node(p, "C", mathx.Vec2{Y: 1}))
|
|
*c = components.DefaultCircle()
|
|
f.s.Flush()
|
|
f.sim.Sync()
|
|
mass, inertia, center, _ := f.sim.World().BodyMass(f.handle(p))
|
|
|
|
ref, _ := world.New(world.DefaultConfig())
|
|
rb, _ := ref.CreateBody(world.DefaultBodyDef())
|
|
add := func(g world.Geometry, at mathx.Vec2) {
|
|
d := world.DefaultShapeDef(g)
|
|
d.Local = world.Transform{Position: at}
|
|
if _, err := ref.AddShape(rb, d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
add(world.Box{Half: mathx.Vec2{X: 0.5, Y: 0.5}}, mathx.Vec2{X: 1})
|
|
add(world.Box{Half: mathx.Vec2{X: 0.5, Y: 1}}, mathx.Vec2{X: -1})
|
|
add(world.Circle{R: 0.5}, mathx.Vec2{Y: 1})
|
|
wm, wi, wc, _ := ref.BodyMass(rb)
|
|
if !near(mass, wm, 1e-4) || !near(inertia, wi, 1e-3) || !near(center.X, wc.X, 1e-4) || !near(center.Y, wc.Y, 1e-4) {
|
|
t.Fatalf("compound %v %v %v, world %v %v %v", mass, inertia, center, wm, wi, wc)
|
|
}
|
|
}
|
|
|
|
func TestStaticFollowsNode(t *testing.T) {
|
|
f := newFixture(t)
|
|
g := f.ground()
|
|
f.step(1)
|
|
f.s.SetPosition(g, mathx.Vec2{X: 3, Y: 5})
|
|
f.step(1)
|
|
if p, _, _ := f.sim.World().BodyTransform(f.handle(g)); p != (mathx.Vec2{X: 3, Y: 5}) {
|
|
t.Fatalf("static body at %v", p)
|
|
}
|
|
}
|
|
|
|
func TestWritebackUnderRotatedParent(t *testing.T) {
|
|
f := newFixture(t)
|
|
pivot := f.node(scene.NodeHandle{}, "Pivot", mathx.Vec2{X: 2})
|
|
f.s.SetRotation(pivot, mathx.Pi/2)
|
|
b := f.node(pivot, "Crate", mathx.Vec2{X: 1})
|
|
f.body(b, components.Dynamic)
|
|
f.box(b, mathx.Vec2{X: 1, Y: 1})
|
|
f.step(30)
|
|
p, a, _ := f.sim.World().BodyTransform(f.handle(b))
|
|
if p.Y < 1.5 {
|
|
t.Fatalf("crate did not fall, at %v", p)
|
|
}
|
|
wp := f.s.WorldPosition(b)
|
|
if !near(wp.X, p.X, 1e-3) || !near(wp.Y, p.Y, 1e-3) {
|
|
t.Fatalf("node at %v, body at %v", wp, p)
|
|
}
|
|
m := f.s.World(b)
|
|
d := mathx.Atan2(m[1], m[0]) - a
|
|
for d > mathx.Pi {
|
|
d -= mathx.TwoPi
|
|
}
|
|
for d < -mathx.Pi {
|
|
d += mathx.TwoPi
|
|
}
|
|
if mathx.Abs(d) > 1e-3 {
|
|
t.Fatalf("node angle differs from body angle by %v", d)
|
|
}
|
|
}
|
|
|
|
func TestInterpolationUsesRenderWorld(t *testing.T) {
|
|
f := newFixture(t)
|
|
b, cfg := f.crate(mathx.Vec2{})
|
|
cfg.Interpolate = true
|
|
child := f.node(b, "Sprite", mathx.Vec2{X: 1})
|
|
plain, _ := f.crate(mathx.Vec2{X: 5})
|
|
f.step(10)
|
|
f.sim.Present(0.5)
|
|
w, r := f.s.World(b), f.s.RenderWorld(b)
|
|
if !(r[7] < w[7]) || w[7]-r[7] > 0.1 {
|
|
t.Fatalf("render y %v, world y %v", r[7], w[7])
|
|
}
|
|
if rc := f.s.RenderWorld(child); !near(rc[7], r[7], 1e-5) || !near(rc[6], r[6]+1, 1e-5) {
|
|
t.Fatalf("child render %v %v, parent render %v %v", rc[6], rc[7], r[6], r[7])
|
|
}
|
|
if f.s.World(child)[7] != w[7] {
|
|
t.Fatal("child world moved with the presentation pose")
|
|
}
|
|
if f.s.RenderWorld(plain) != f.s.World(plain) {
|
|
t.Fatal("body without Interpolate has a presentation pose")
|
|
}
|
|
}
|
|
|
|
func TestDestroyReportsEnd(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.ground()
|
|
b, _ := f.crate(mathx.Vec2{X: 0, Y: 4})
|
|
f.step(30)
|
|
f.s.Destroy(b)
|
|
if f.s.IsAlive(b) {
|
|
t.Fatal("destroyed node is alive")
|
|
}
|
|
f.step(1)
|
|
ends := 0
|
|
for _, e := range f.sim.World().Events().Contacts {
|
|
if e.Kind == world.EndContact {
|
|
ends++
|
|
}
|
|
}
|
|
if ends != 1 || f.sim.Stats().Bodies != 1 {
|
|
t.Fatalf("end events %d, bodies %d", ends, f.sim.Stats().Bodies)
|
|
}
|
|
}
|
|
|
|
func TestDisabledSubtree(t *testing.T) {
|
|
f := newFixture(t)
|
|
p := f.node(scene.NodeHandle{}, "Body", mathx.Vec2{})
|
|
f.body(p, components.Dynamic)
|
|
f.box(f.node(p, "A", mathx.Vec2{X: 1}), mathx.Vec2{X: 1, Y: 1})
|
|
f.box(p, mathx.Vec2{X: 1, Y: 1})
|
|
f.step(1)
|
|
f.s.SetEnabled(p, false)
|
|
f.step(1)
|
|
w := f.sim.World()
|
|
for _, sh := range w.BodyShapes(f.handle(p)) {
|
|
if w.ShapeEnabled(sh) {
|
|
t.Fatal("shape of a disabled node is enabled")
|
|
}
|
|
}
|
|
f.s.SetEnabled(p, true)
|
|
f.step(1)
|
|
for _, sh := range w.BodyShapes(f.handle(p)) {
|
|
if !w.ShapeEnabled(sh) {
|
|
t.Fatal("shape stayed disabled")
|
|
}
|
|
}
|
|
}
|
|
|
|
func hasDiag(f *fixture, code string) bool {
|
|
ds, _ := f.sim.Diagnostics()
|
|
for _, d := range ds {
|
|
if d.Code == code {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestNonUniformCircleExcludesBody(t *testing.T) {
|
|
f := newFixture(t)
|
|
h := f.node(scene.NodeHandle{}, "Ball", mathx.Vec2{})
|
|
f.body(h, components.Dynamic)
|
|
c := scene.Add[components.CircleCollider2D](f.s, h)
|
|
*c = components.DefaultCircle()
|
|
f.s.SetScale(h, mathx.Vec2{X: 2, Y: 1})
|
|
f.step(10)
|
|
if !hasDiag(f, systems.CodeScale) {
|
|
t.Fatal("no diagnostic for a circle under non-uniform scale")
|
|
}
|
|
if p := f.s.Local(h).Position; p.Y != 0 {
|
|
t.Fatalf("excluded body moved to %v", p)
|
|
}
|
|
f.s.SetScale(h, mathx.Vec2{X: 2, Y: 2})
|
|
f.step(10)
|
|
if hasDiag(f, systems.CodeScale) {
|
|
t.Fatal("diagnostic stayed after the fix")
|
|
}
|
|
if f.s.Local(h).Position.Y <= 0 {
|
|
t.Fatal("body did not resume")
|
|
}
|
|
}
|
|
|
|
func TestReparentKeepsPoseAndVelocity(t *testing.T) {
|
|
f := newFixture(t)
|
|
b, _ := f.crate(mathx.Vec2{})
|
|
f.step(20)
|
|
v0, _, _ := f.sim.World().BodyVelocity(f.handle(b))
|
|
p0 := f.s.WorldPosition(b)
|
|
holder := f.node(scene.NodeHandle{}, "Holder", mathx.Vec2{X: 5, Y: -3})
|
|
if err := f.s.Reparent(b, holder, -1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.s.Flush()
|
|
f.sim.Sync()
|
|
v1, _, _ := f.sim.World().BodyVelocity(f.handle(b))
|
|
p1 := f.s.WorldPosition(b)
|
|
if !near(v0.Y, v1.Y, 1e-4) || !near(p0.X, p1.X, 1e-4) || !near(p0.Y, p1.Y, 1e-4) {
|
|
t.Fatalf("velocity %v → %v, position %v → %v", v0, v1, p0, p1)
|
|
}
|
|
if !hasDiag(f, systems.CodeReparent) {
|
|
t.Fatal("no reparent diagnostic")
|
|
}
|
|
}
|
|
|
|
func TestDistanceJointHoldsLength(t *testing.T) {
|
|
f := newFixture(t)
|
|
anchor := f.node(scene.NodeHandle{}, "Anchor", mathx.Vec2{})
|
|
f.body(anchor, components.Static)
|
|
f.box(anchor, mathx.Vec2{X: 0.2, Y: 0.2})
|
|
bob, _ := f.crate(mathx.Vec2{X: 2})
|
|
j := scene.Add[components.DistanceJoint2D](f.s, bob)
|
|
*j = components.DefaultDistance()
|
|
j.Target.SetNodeRefID(f.s.ID(anchor))
|
|
f.step(120)
|
|
if d := f.s.WorldPosition(bob).Len(); !near(d, 2, 0.05) {
|
|
t.Fatalf("distance %v", d)
|
|
}
|
|
if f.sim.Stats().Joints != 1 {
|
|
t.Fatal("joint is missing")
|
|
}
|
|
}
|
|
|
|
func TestSceneWithoutPhysicsKeepsComponents(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.crate(mathx.Vec2{X: 1})
|
|
f.s.Flush()
|
|
data := sceneio.Save(f.s)
|
|
plain := scene.NewTypes(schema.NewRegistry())
|
|
if err := sceneio.RegisterUnknown(plain); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := plain.Schema().Freeze(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, rep, err := sceneio.Load(data, "t", plain, nil, sceneio.LoadOptions{KeepIDs: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rep.Unknown) != 2 {
|
|
t.Fatalf("unknown %v", rep.Unknown)
|
|
}
|
|
if got := sceneio.Save(s); string(got) != string(data) {
|
|
t.Fatalf("round trip differs:\n%s\n---\n%s", data, got)
|
|
}
|
|
}
|
|
|
|
func TestAttachOnlyInPlay(t *testing.T) {
|
|
f := newFixture(t)
|
|
m := physics.New()
|
|
w := engine.NewWorld("edit")
|
|
edit := &module.AttachContext{World: w, Scene: f.s, Editor: true}
|
|
if err := m.AttachWorld(edit); err != nil || physics.SimOf(w) != nil {
|
|
t.Fatal("edit world got a physics world")
|
|
}
|
|
m.DetachWorld(edit)
|
|
play := &module.AttachContext{World: w, Scene: f.s, Editor: true, Play: true}
|
|
if err := m.AttachWorld(play); err != nil || physics.SimOf(w) == nil {
|
|
t.Fatalf("play world has no physics: %v", err)
|
|
}
|
|
m.DetachWorld(play)
|
|
if physics.SimOf(w) != nil {
|
|
t.Fatal("detach left the service")
|
|
}
|
|
}
|
|
|
|
func TestStepAllocatesNothing(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.ground()
|
|
for i := 0; i < 20; i++ {
|
|
f.crate(mathx.Vec2{X: float32(i%5) - 2, Y: float32(-i)})
|
|
}
|
|
f.step(120)
|
|
if n := testing.AllocsPerRun(30, func() { f.step(1) }); n != 0 {
|
|
t.Fatalf("%v allocations per step", n)
|
|
}
|
|
}
|