Files
aego/modules/animation/animator_step.go
sanspie 72d9a21782
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 8m33s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m38s
ci / release (push) Successful in 9m25s
Stage 12w: web runtime performance (D365, D366, D390, D391)
make web-bench measures the exported platformer in Chromium and Firefox
in lockstep, with exact WebGL counters against a per-mode baseline, time
compared with Mann-Whitney and a profile pass that gates allocations in
the engine frame to zero, except listed first-decode and growth sites.

On the web the frame loop fills the audio output, the stream feeder
waits for ring space, per-event allocations are gone and Go allocations
per frame drop from 7.2 to 3.8 in Chromium. The pixel ratio is capped at
1.5 by default (display.maxPixelRatio) and Frame Limit works on the web.
The game loop stays on the main thread; a Worker is not built (D391).
2026-10-27 16:29:41 +03:00

595 lines
14 KiB
Go

package animation
import (
"slices"
"aego/anim"
"aego/contracts/physics2d"
"aego/core/ids"
"aego/core/mathx"
"aego/scene"
"aego/scene/propclaim"
"aego/scene/propref"
"aego/schema"
)
type tmpRef struct {
ref propref.Ref
node scene.NodeHandle
state int32
track int32
}
type animator struct {
node scene.NodeHandle
slot int32
ce *ctrlEntry
id ids.AssetID
gen uint32
binds []binding
prev []binding
vals []anim.Value
tracks [][]int32
samp []anim.Sampler
ids [][]int32
changes []anim.Change
refs []tmpRef
root []int32
entered int32
mark uint32
}
func (s *Service) alookup(h scene.NodeHandle, a *Animator) *animator {
if i := int(a.st.slot) - 1; i >= 0 && i < len(s.alist) {
if r := s.alist[i]; r.node == h {
return r
}
}
r := s.anims[h]
if r != nil {
a.st.slot = r.slot
}
return r
}
func (s *Service) acreate(h scene.NodeHandle, a *Animator) *animator {
r := &animator{node: h, slot: int32(len(s.alist) + 1), entered: -1}
s.anims[h] = r
s.alist = append(s.alist, r)
a.st.slot = r.slot
return r
}
func ctrlCompatible(a, b *ctrlEntry) bool {
ga, gb := a.g, b.g
if len(ga.States) != len(gb.States) || len(ga.Params) != len(gb.Params) {
return false
}
for i := range ga.States {
if ga.States[i].Name != gb.States[i].Name {
return false
}
}
for i := range ga.Params {
if ga.Params[i].Name != gb.Params[i].Name || ga.Params[i].Kind != gb.Params[i].Kind {
return false
}
}
return true
}
func (s *Service) aloadOne(h scene.NodeHandle, a *Animator) {
r := s.alookup(h, a)
if r == nil {
r = s.acreate(h, a)
s.dirty = true
}
if a.PlayOnStart && !a.st.auto {
a.st.auto, a.st.on = true, true
}
res := s.scene.Resolver()
var gen uint32
if res != nil {
gen = res.Generation()
}
if r.id == a.Controller.ID && r.gen == gen {
// a rewind may restore a state captured before the controller was first applied
if r.ce != nil {
r.ce.prepare(a)
}
return
}
e := s.m.ctrl(res, a.Controller.ID)
old, same := r.ce, r.id == a.Controller.ID
r.id, r.gen = a.Controller.ID, gen
switch {
case e == old:
return
case same && e == nil:
return
case same && old != nil && !ctrlCompatible(old, e):
if s.once(h, CodeReload, -1) {
s.warn(h, CodeReload, "the controller was reimported with other states or parameters; the animator restarts")
}
e.reset(a)
case e != nil:
e.prepare(a)
}
r.ce = e
r.ids = r.ids[:0]
if e != nil {
for st, clip := range e.clips {
if clip == nil && !e.g.States[st].Clip.IsZero() && s.once(h, CodeCtrlClip, int32(st)) {
s.warn(h, CodeCtrlClip, "state "+e.g.States[st].Name+": the clip is not loaded; the state plays nothing")
}
var list []int32
if clip != nil {
list = s.eventIDs(h, clip, nil)
}
r.ids = append(r.ids, list)
}
}
s.dirty = true
}
func resizeTracks(t [][]int32, states, slots int) [][]int32 {
if cap(t) < states {
t = make([][]int32, states)
}
t = t[:states]
for i := range t {
if cap(t[i]) < slots {
t[i] = make([]int32, slots)
}
t[i] = t[i][:slots]
for j := range t[i] {
t[i][j] = -1
}
}
return t
}
func (s *Service) abindOne(h scene.NodeHandle, a *Animator) {
if !s.scene.IsAlive(h) {
return
}
r := s.alookup(h, a)
if r == nil {
r = s.acreate(h, a)
}
r.mark = s.mark
r.prev, r.binds = r.binds, r.prev[:0]
r.refs = r.refs[:0]
if r.ce != nil {
g := r.ce.g
r.root = slices.Grow(r.root[:0], len(g.States))[:len(g.States)]
for st, clip := range r.ce.clips {
r.root[st] = -1
if clip == nil {
continue
}
for ti := range clip.Tracks {
if clip.Tracks[ti].Root {
r.root[st] = int32(ti)
break
}
}
}
for st, clip := range r.ce.clips {
if clip == nil {
continue
}
for ti := range clip.Tracks {
if !clip.Tracks[ti].Root {
s.abindTrack(h, a, r, int32(st), int32(ti))
}
}
}
slices.SortFunc(r.binds, bindOrder)
r.tracks = resizeTracks(r.tracks, len(g.States), len(r.binds))
for _, t := range r.refs {
for j := range r.binds {
if r.binds[j].node == t.node && r.binds[j].ref.Same(&t.ref) {
r.tracks[t.state][j] = t.track
break
}
}
}
if len(r.samp) < len(g.States) {
r.samp = make([]anim.Sampler, len(g.States))
}
for st, clip := range r.ce.clips {
if clip != nil {
r.samp[st].Reset(clip)
}
}
for i := range r.binds {
carry(&r.binds[i], r.prev)
}
}
r.vals = sizeVals(r.vals, len(r.binds))
for i := range r.prev {
if o := &r.prev[i]; o.track >= 0 && o.shown && !posed(r.binds, o.node) {
s.scene.DropPresentationLocal(o.node)
}
}
}
func (s *Service) abindTrack(h scene.NodeHandle, a *Animator, r *animator, st, ti int32) {
t := &r.ce.clips[st].Tracks[ti]
state := r.ce.g.States[st].Name
key := st*1024 + ti
node := s.scene.Find(h, t.Target)
if node.IsZero() || !s.scene.IsAlive(node) {
if s.once(h, CodeLost, key) {
s.warn(h, CodeLost, "track "+stateTrack(state, t)+": the target node is missing")
}
return
}
ref, err := s.props.ResolveAny(node, t.Property)
if err != nil {
if s.once(h, CodeTrack, key) {
s.warn(h, CodeTrack, "track "+stateTrack(state, t)+": "+err.Error())
}
return
}
if !fits(t.Kind, &ref) {
if s.once(h, CodeTrack, key) {
s.warn(h, CodeTrack, "track "+stateTrack(state, t)+" has kind "+t.Kind.String()+", which does not match the property")
}
return
}
if a.TimeDomain == DomainUnscaled && t.Authority == anim.AuthSimulation {
if s.once(h, CodeUnscaled, key) {
s.warn(h, CodeUnscaled, "track "+stateTrack(state, t)+" has authority=simulation in an unscaled animator; it is off")
}
return
}
for j := range r.binds {
if b := &r.binds[j]; b.node == node && b.ref.Same(&ref) {
if b.kind != t.Kind {
if s.once(h, CodeTrack, key) {
s.warn(h, CodeTrack, "track "+stateTrack(state, t)+" has kind "+t.Kind.String()+" here and "+b.kind.String()+" in another state; it is off here")
}
return
}
r.refs = append(r.refs, tmpRef{ref: ref, node: node, state: st, track: ti})
return
}
}
k := propclaim.KeyOf(node, &ref)
for _, o := range s.owned[k] {
if o.Overlaps(&ref) {
if s.once(h, CodeConflict, key) {
s.warn(h, CodeConflict, "track "+stateTrack(state, t)+": another animation track drives this property; this one is off")
}
return
}
}
s.owned[k] = append(s.owned[k], ref)
r.refs = append(r.refs, tmpRef{ref: ref, node: node, state: st, track: ti})
r.binds = append(r.binds, binding{
ref: ref, key: k, state: state, tr: t, node: node, kind: t.Kind, auth: t.Authority,
pose: ref.Owner() == propref.OwnTransform && !ref.Three(),
})
}
func (s *Service) astepOne(h scene.NodeHandle, a *Animator) {
r := s.alookup(h, a)
if r == nil || r.ce == nil {
s.apublish(a, r)
return
}
r.entered = -1
if a.TimeDomain == DomainGame {
s.acommands(h, a, r)
if a.st.on {
s.aadvance(h, a, r, s.dt)
}
}
s.gate(h, r.binds)
if a.TimeDomain == DomainGame {
s.asample(a, r, 0, false)
s.writeValues(r.binds, r.vals)
s.writePoses(r.binds, r.vals, true)
}
s.claim(r.binds)
s.apublish(a, r)
}
func (s *Service) apresentOne(h scene.NodeHandle, a *Animator) {
r := s.alookup(h, a)
if r == nil || r.ce == nil {
return
}
if a.TimeDomain == DomainUnscaled {
r.entered = -1
s.acommands(h, a, r)
if a.st.on {
s.aadvance(h, a, r, s.unscaled)
}
s.asample(a, r, 0, false)
s.writeValues(r.binds, r.vals)
s.writePoses(r.binds, r.vals, false)
s.apublish(a, r)
return
}
var ahead float32
if a.st.on {
ahead = s.alpha * s.fixed * a.Speed
}
s.asample(a, r, ahead, true)
s.writePoses(r.binds, r.vals, false)
}
func (s *Service) acommands(h scene.NodeHandle, a *Animator, r *animator) {
cmd := a.st.cmd
if cmd == 0 {
return
}
a.st.cmd = 0
a.st.on = true
g := r.ce.g
if cmd == acmdFade {
r.changes = a.st.m.CrossFade(g, a.st.target, a.st.fade, r.changes[:0])
} else {
r.changes = a.st.m.Play(g, a.st.target, r.changes[:0])
}
s.changed(h, a, r)
}
func (s *Service) aadvance(h scene.NodeHandle, a *Animator, r *animator, dt float32) {
g := r.ce.g
n := r.ce.nParams()
step := dt * a.Speed
r.changes = a.st.m.Step(g, step, a.st.params[:n], r.changes[:0])
g.Consume(a.st.params[:n])
s.changed(h, a, r)
m := &a.st.m
if !m.Started {
return
}
s.stateEvents(h, a, r, m.State, m.Last, m.Time, r.entered == m.State, true, step)
if m.Blend && m.Prev >= 0 {
s.stateEvents(h, a, r, m.Prev, m.PrevLast, m.PrevTime, false, false, step)
}
if a.RootMotion == RootNone {
return
}
if a.TimeDomain != DomainGame {
if s.once(h, CodeUnscaled, -4) {
s.warn(h, CodeUnscaled, "root motion of an unscaled animator is off; it changes the game state")
}
return
}
s.rootMotion(h, a, r)
}
func (s *Service) rootDelta(r *animator, st int32, t0, t1 float64) [2]float32 {
if st < 0 || int(st) >= len(r.root) || r.root[st] < 0 {
return [2]float32{}
}
return anim.RootDelta(r.ce.clips[st], &r.samp[st], int(r.root[st]), r.ce.g.States[st].Loop, t0, t1)
}
func (s *Service) rootMotion(h scene.NodeHandle, a *Animator, r *animator) {
if s.scene.Space(h) != scene.Space2D {
return
}
m := &a.st.m
d := s.rootDelta(r, m.State, m.Last, m.Time)
if m.Blend {
var o [2]float32
if m.Prev >= 0 {
o = s.rootDelta(r, m.Prev, m.PrevLast, m.PrevTime)
}
w := m.Weight(r.ce.g)
d = [2]float32{o[0] + (d[0]-o[0])*w, o[1] + (d[1]-o[1])*w}
}
if d == ([2]float32{}) {
return
}
local := mathx.Vec2{X: d[0] * a.RootScale.X, Y: d[1] * a.RootScale.Y}
if a.RootMotion == RootToMotion {
world := s.scene.World(s.scene.Parent(h)).ApplyVector(local)
if s.rcv != nil && s.rcv.AddDisplacement(h, world) {
return
}
if s.once(h, CodeRoot, -1) {
s.warn(h, CodeRoot, "root motion needs a Motion 2D on the node to move it")
}
if !a.RootFallback {
return
}
}
s.rootTransform(h, local)
}
func (s *Service) rootTransform(h scene.NodeHandle, d mathx.Vec2) {
ref := propref.TransformRef(s.scene, h, propref.PartPosition)
if o, held := s.cl.Held(h, &ref, s.owner); held {
if s.once(h, CodeConflict, -3) {
s.warn(h, CodeConflict, "root motion: "+s.cl.Name(o)+" drives the position; use rootMotion toMotion")
}
return
}
if s.q != nil {
if bt, ok := s.q.BodyType(h); ok && bt == physics2d.Dynamic {
if s.once(h, CodeBody, -3) {
s.warn(h, CodeBody, "root motion: a dynamic body moves this node; root motion is off")
}
return
}
}
tr := s.scene.Local(h)
tr.Position = tr.Position.Add(d)
s.moveNode(h, tr)
s.cl.Claim(s.owner, h, &ref)
}
func (s *Service) changed(h scene.NodeHandle, a *Animator, r *animator) {
g := r.ce.g
entered := false
for _, ch := range r.changes {
switch ch.Kind {
case anim.ChangeEnter:
entered = true
r.entered = ch.State
s.pend = append(s.pend, pending{node: h, name: g.States[ch.State].Name, id: ch.State, kind: pendEntered})
case anim.ChangeExit:
s.pend = append(s.pend, pending{node: h, name: g.States[ch.State].Name, id: ch.State, kind: pendExited})
}
}
if !entered {
return
}
blend := a.st.m.Blend
for i := range r.binds {
b := &r.binds[i]
switch {
case !b.live:
b.base, b.hasBase = s.read(b)
b.live, b.wrote = true, false
case blend:
b.base = s.current(b)
}
}
}
func dominates(g *anim.Graph, m *anim.Machine, pol anim.EventPolicy, incoming bool, tau, t0, t1 float64, step float32) bool {
switch pol {
case anim.EventsBoth:
return true
case anim.EventsIncoming:
return incoming
}
frac := float32(1)
if t1 != t0 {
frac = float32((tau - t0) / (t1 - t0))
}
bt0 := max(m.BlendT-step, 0)
w := m.WeightAt(g, bt0+frac*(m.BlendT-bt0))
if incoming {
return w >= 0.5
}
return w < 0.5
}
func (s *Service) stateEvents(h scene.NodeHandle, a *Animator, r *animator, st int32, t0, t1 float64, start, incoming bool, step float32) {
clip := r.ce.clips[st]
if clip == nil || len(clip.Events) == 0 {
return
}
g := r.ce.g
m := &a.st.m
s.cross = anim.Crossings(clip, g.States[st].Loop, t0, t1, start, s.cross)
pol := g.States[m.State].Events
unscaled := a.TimeDomain == DomainUnscaled
for _, x := range s.cross {
e := &clip.Events[x.Event]
id := r.ids[st][x.Event]
if id < 0 || e.Mode == anim.EventPreview {
continue
}
if m.Blend && !dominates(g, m, pol, incoming, x.T, t0, t1, step) {
continue
}
pres := e.Mode == anim.EventPresentation || unscaled
if pres && s.world.Replaying() {
continue
}
if unscaled && e.Mode == anim.EventGameplay && s.once(h, CodeUnscaled, -2-x.Event) {
s.warn(h, CodeUnscaled, "gameplay event "+e.Name+" of an unscaled animator is delivered as a presentation event")
}
s.pend = append(s.pend, pending{node: h, name: e.Name, id: id, payload: e.Payload, cycle: x.Cycle, anim: true, pres: pres})
}
}
func (r *animator) value(st int32, slot int, t float64) (anim.Value, bool) {
ti := r.tracks[st][slot]
if ti < 0 {
return anim.Value{}, false
}
clip := r.ce.clips[st]
local, _ := clip.Local(r.ce.g.States[st].Loop, t)
return r.samp[st].Track(clip, int(ti), local), true
}
func mix(k schema.Kind, a, b anim.Value, w float32) anim.Value {
switch {
case k == schema.KindQuat:
return anim.Lanes(anim.Slerp(a.L, b.L, w))
case anim.Interpolates(k):
var v anim.Value
for i := range v.L {
v.L[i] = a.L[i] + (b.L[i]-a.L[i])*w
}
return v
case w >= 0.5:
return b
}
return a
}
func (s *Service) asample(a *Animator, r *animator, ahead float32, poseOnly bool) {
g := r.ce.g
m := &a.st.m
if !m.Started {
for i := range r.binds {
r.binds[i].idle = true
}
return
}
w := float32(1)
if m.Blend {
w = m.WeightAt(g, m.BlendT+ahead)
}
cur := m.Time + float64(ahead)*float64(g.States[m.State].Speed)
var prev float64
if m.Blend && m.Prev >= 0 {
prev = m.PrevTime + float64(ahead)*float64(g.States[m.Prev].Speed)
}
for i := range r.binds {
b := &r.binds[i]
if poseOnly && !b.pose {
continue
}
b.idle = false
if !b.live || b.blocked {
continue
}
in, ok := r.value(m.State, i, cur)
if !m.Blend {
if ok {
r.vals[i] = in
} else {
b.idle = true
}
continue
}
if !ok {
in = b.base
}
out := b.base
if m.Prev >= 0 {
if v, ok := r.value(m.Prev, i, prev); ok {
out = v
}
}
r.vals[i] = mix(b.kind, out, in, w)
}
}
func (s *Service) apublish(a *Animator, r *animator) {
m := &a.st.m
a.Blending = m.Blend
if r == nil || r.ce == nil || !m.Started {
a.State, a.Time, a.Progress = "", 0, 0
return
}
g := r.ce.g
a.State = g.States[m.State].Name
a.Time = float32(m.Time)
a.Progress = m.Progress(g)
}