As promised, here’s the context & workarounds (because I found almost nothing when I searched for this stuff).
Context
Many things in a game involve co-ordinating the animation of properties across arbitrary sets of objects - from characters opening doors, to attack animations, to environmental object effects - controlling / syncing animation, Vfx, audio, collider enabling etc etc. all of which is very set-up heavy & often involve custom editor tooling etc.
The Timeline package does all of the above, and has a mature editor integration so seemed a logical choice for the team early in pre-production, and the testing the pre-production team did didn’t show horrendous performance hits (on PC…) so the game went on to makes very heavy use of Timeline for core gameplay interactions throughout its development.
I’m currently working on a multiplatform title targeting PC / Mac / Steam Deck and all current gen consoles.
The game runs at a very solid 60fps on PC, but when we hit beta and started the serious process of profiling and optimising on some of the less computationally powerful hardware platforms we found that Timeline had some very unfortunate characteristics which were seriously affecting framerate stability.
PlayableDirector.Play()
was costing as much as 20ms (on the first call, subsequent calls were cheaper but still typically 1-2ms. This meant that:
PlayableDirector.Play()
(the 1st time each PlayableDirector
is used) is a guaranteed frame drop (1 frame @60fps : 16.66…ms @30fps : 33.33…ms)
- even the lower 1-2ms cost of re-
Play()
ing is a pretty big problem - especially since we often have 2 or 3 calls on the same frame in active gameplay
Attempted Workaround: Manage Our Own Playable
Initially I assumed it would be easy to just use TimelineAsset.CreatePlayable()
and manage our own PlayableGraph
.
It turns out that this is actually non-trivial:
- partly because the game uses custom timeline tracks, some of which rely on there being a
PlayableDirector
…
- …but also because the playables API is
I spent a morning trying to work out how to use the binding data on a PlayableDirector
instance to set up the outputs of a PlayableGraph
(created using playableDirector.PlayableAsset.CreatePlayable()
) and gave up.
I’m sure it’s completely doable, but I couldn’t find any sample code which explained how to do it, or find anything in the documentation which shed much light on it either, and because the objects in a PlayableGraph
arre all handles I found that my usual approach of using the debugger to try to make sense of the data at runtime didn’t work, so because time pressure I decided to try something else.
Actual Workaround: Abuse the PlayableDirector API to prevent it destroying its PlayableGraph
On a hunch, I tried setting the PlayableDirector
wrap mode to hold
and discovered that this prevents it ever properly stopping and therefore it doesn’t destroy or recreate its PlayableGraph
.
There was another fly in the ointment though, even with the wrap mode set to hold
, if the parent GameObject
of a PlayableDirector
is disabled then this also causes the PlayableGraph
to be destroyed; the way the game manages much of the behaviour of the objects using Timeline
is to (en/dis)able them so this needed an additional layer of workaround…
The final workaround for was to clone all PlayableDirector
instances in the game, and cache them in an area of the hierarchy which is never disabled.
For each PlayableDirector
used in active gameplay:
- create a new
PlayableDirector
…
- …copy all the generic bindings from the original…
- …for tracks which use other binding approaches (e.g.
ControlTrack
) manually iterate the original’s asset bindings and copy them over
- prevent the cost on initial
Play()
by using RebuildGraph()
- avoid the automatic destruction a
PlayableDirector.playableGraph
by
- setting
playableDirector.extrapolationMode = DirectorWrapMode.Hold
(which prevents the timeline stopping automatically)
- check for the
Timeline
ending with Time
vs. Duration
of the timeline Playable
and use this to send an event equivalent to PlayableDirector.stopped
- instead of
Stop()
call Pause()
then reset PlayableDirector.time
Summary
Several days and a lot of stress would all have been saved if PlayableDirector
had an option to reuse its PlayableGraph
rather than destroy / recreate it constantly.