Last active
December 16, 2024 10:10
-
-
Save darbotron/37386f74a9a45c5efc088a1dfd9adbcb to your computer and use it in GitHub Desktop.
How to create a PlayableGraph from a TimelineAsset whilst copying over all the bindings from a PlayableDirector
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/////////////////////////////////////////////////////////////////////////////// | |
// License: https://opensource.org/licenses/unlicense | |
// | |
// TL;DR: | |
// 1) you may do what you like with it... | |
// 2) ...except blame me for any consequence of acting on rule 1) | |
// | |
/////////////////////////////////////////////////////////////////////////////// | |
using System.Collections.Generic; | |
using UnityEngine; | |
using UnityEngine.Animations; | |
using UnityEngine.Audio; | |
using UnityEngine.Playables; | |
using UnityEngine.Pool; | |
using UnityEngine.Timeline; | |
////////////////////////////////////////////////////////////////////////////// | |
// | |
// see these unity discussions topics for context: | |
// https://discussions.unity.com/t/simple-suggestion-to-prevent-performance-issues-caused-by-timeline-playabledirector/1569312 | |
// https://discussions.unity.com/t/when-using-timelineasset-createplayable-to-make-a-playablegraph-playablegraph-getresolver-is-always-null-how-do-we-create-a-resolver-for-it/1569623 | |
// | |
// Add an instance of this to a GameObject with a PlaybleDirector | |
// | |
// It will create a PlayableGraph from the PlaybleDirector's Timeline | |
// and copy all the binding data from the PlayableDirector into it so that you | |
// can just use the PlaybleGraph instead of the PlayableDirector. | |
// | |
// This is SUPER handy, since the default behaviour of PlayableDirector is to: | |
// 1. create a new PlayableGraph when you call Play() and | |
// 2. destroy it when either: you call Stop(), playback ends naturally, or the | |
// GameObject owning the PlayableDirector is disabled | |
// | |
// Creating a PlayableGraph can be very expensive - up to 20ms on the least | |
// powerful current gen console platform when I'm typing this... | |
// | |
// This is fine if you're using it for a cinematic, but if you're using it for | |
// active gameplay reasons then 20ms for calling PlayableDirector.Play() is | |
// "somewhat problematic". | |
// | |
// Known limitations: | |
// * any timeline track which uses ExposedReference< T > on its timeline clips | |
// (like ControlTrack) will need special handling in initialisation to copy | |
// all of the property bindings over to the cloned Timeline in | |
// (see: CreatePlayableGraphExposedReferenceResolver() ) | |
////////////////////////////////////////////////////////////////////////////// | |
public class CloneTimelineToPlayableGraph : MonoBehaviour | |
{ | |
//------------------------------------------------------------------------ | |
private void Awake() | |
{ | |
m_director = GetComponent< PlayableDirector >(); | |
m_director.enabled = false; | |
var playbleGraphGo = new GameObject( "customPlayable" ); | |
m_playableGraph = CreateStandalonePlayableGraphWithBindingsFromDirector( m_director, playbleGraphGo, CreatePlayableGraphExposedReferenceResolver( m_director ) ); | |
LogTimelineSummary( m_playableGraph ); | |
m_timelineAsPlayable = m_playableGraph.GetRootPlayable( 0 ); | |
Debug.Assert( m_timelineAsPlayable.GetPlayableType() == typeof( TimelinePlayable ) ); | |
} | |
//------------------------------------------------------------------------ | |
private void Update() | |
{ | |
void Stop() | |
{ | |
m_playableGraph.Stop(); | |
m_timelineAsPlayable.SetTime( 0f ); | |
m_playableGraph.Evaluate( 0f ); | |
} | |
var spacePressed = Input.GetKeyDown( KeyCode.Space ); | |
if( m_playableGraph.IsPlaying() ) | |
{ | |
if( spacePressed | |
|| ( m_timelineAsPlayable.GetTime() == m_timelineAsPlayable.GetDuration() ) ) | |
{ | |
Stop(); | |
} | |
} | |
else if( spacePressed ) | |
{ | |
m_playableGraph.Play(); | |
} | |
} | |
//------------------------------------------------------------------------ | |
private static PlayableGraphExposedReferenceResolver CreatePlayableGraphExposedReferenceResolver( PlayableDirector directorOwningTimelineAsset ) | |
{ | |
var exposedReferenceResolver = new PlayableGraphExposedReferenceResolver(); | |
foreach( var timelineOutput in directorOwningTimelineAsset.playableAsset.outputs ) | |
{ | |
var timelineTrackAsset = timelineOutput.sourceObject; | |
if( timelineTrackAsset is ControlTrack controlTrack ) | |
{ | |
if( null != controlTrack ) | |
{ | |
foreach( var timelineClip in controlTrack.GetClips() ) | |
{ | |
var clipAsset = timelineClip.asset as ControlPlayableAsset; | |
var controlClipBinding = directorOwningTimelineAsset.GetReferenceValue( clipAsset.sourceGameObject.exposedName, out var isValid ); | |
if( isValid ) | |
{ | |
exposedReferenceResolver.SetReferenceValue( clipAsset.sourceGameObject.exposedName, controlClipBinding ); | |
} | |
} | |
} | |
} | |
} | |
return exposedReferenceResolver; | |
} | |
//------------------------------------------------------------------------ | |
public static PlayableGraph CreateStandalonePlayableGraphWithBindingsFromDirector( PlayableDirector director, GameObject graphOwner, IExposedPropertyTable exposedReferenceResolver ) | |
{ | |
var playableGraph = PlayableGraph.Create( $"{graphOwner.name}-Standalone-{director.playableAsset.name}" ); | |
playableGraph.SetResolver( exposedReferenceResolver ); | |
director.playableAsset.CreatePlayable( playableGraph, graphOwner ); | |
using( DictionaryPool< int, PlayableOutput >.Get( out var tempDictTrackInstanceIDToPlayableOutput ) ) | |
{ | |
int graphOutputCount = playableGraph.GetOutputCount(); | |
for( int outputIndex = 0; outputIndex < graphOutputCount; ++outputIndex ) | |
{ | |
var output = playableGraph.GetOutput( outputIndex ); | |
if( output.IsOutputValid() ) | |
{ | |
var timelineTrack = output.GetReferenceObject(); | |
if( timelineTrack && ( timelineTrack is TrackAsset trackAsset ) ) | |
{ | |
tempDictTrackInstanceIDToPlayableOutput.Add( trackAsset.GetInstanceID(), output ); | |
} | |
} | |
} | |
foreach( var timelineOutput in director.playableAsset.outputs ) | |
{ | |
// | |
// Context for this code: | |
// * in a timeline asset, timelineOutput.sourceObject is an object derived from TrackAsset | |
// * when the asset is loaded, each TrackAsset (like all Unity.Object) is created with an instance ID | |
// * when a PlayableGraph is created from a TimelineAsset it references these _same_ TrackAssets | |
// * we can use the instanceID to match the timelineOutput to the PlayableGraph outputs | |
// | |
var timelineTrackAsset = timelineOutput.sourceObject; | |
if( timelineTrackAsset == null ) | |
{ | |
Debug.Log( $"sourceDirectorOutput {timelineOutput.streamName} ({timelineOutput.outputTargetType}) has no track asset" ); | |
} | |
var outputTrackBoundObject = director.GetGenericBinding( timelineTrackAsset ); | |
if( outputTrackBoundObject == null ) | |
{ | |
Debug.Log( $"no generic binding set for sourceDirectorOutput {timelineOutput.streamName} ({timelineOutput.outputTargetType})" ); | |
} | |
Debug.Log | |
( | |
$"cloning generic binding on {timelineOutput.streamName} ({timelineOutput.outputTargetType}): " + | |
$"{( ( timelineOutput.sourceObject != null ) ? $"{timelineTrackAsset.name}({timelineOutput.sourceObject.GetInstanceID()})" : "null" )} -> " + $"{( ( outputTrackBoundObject != null ) ? outputTrackBoundObject.name : "null" )}" | |
); | |
if( timelineTrackAsset | |
&& outputTrackBoundObject ) | |
{ | |
if( tempDictTrackInstanceIDToPlayableOutput.TryGetValue( timelineTrackAsset.GetInstanceID(), out var graphOutput ) ) | |
{ | |
if( graphOutput.GetPlayableOutputType() == typeof(AnimationPlayableOutput) ) | |
{ | |
Debug.Assert( outputTrackBoundObject is Animator ); | |
( (AnimationPlayableOutput)graphOutput ).SetTarget( outputTrackBoundObject as Animator ); | |
} | |
else if( graphOutput.GetPlayableOutputType() == typeof(AudioPlayableOutput) ) | |
{ | |
Debug.Assert( outputTrackBoundObject is AudioSource ); | |
( (AudioPlayableOutput)graphOutput ).SetTarget( outputTrackBoundObject as AudioSource ); | |
} | |
else | |
{ | |
graphOutput.SetUserData( outputTrackBoundObject ); | |
} | |
} | |
else | |
{ | |
Debug.Log( $"timelineTrackAsset: {timelineTrackAsset.name}({timelineTrackAsset.GetInstanceID()}) for output not found in TimelineAsset" ); | |
} | |
} | |
} | |
} | |
return playableGraph; | |
} | |
private PlayableDirector m_director; | |
private PlayableGraph m_playableGraph; | |
private Playable m_timelineAsPlayable; | |
//------------------------------------------------------------------------ | |
public static void LogTimelineSummary( PlayableGraph timelineGraph ) | |
{ | |
// using type names so framework doesn't rely on the Timeline package... | |
const string k_TypeName_TimlinePlayable = "TimelinePlayable"; | |
const string k_TypeName_ControlTrack = "ControlTrack"; | |
// still needs to compile if the Timeline | |
if( timelineGraph.GetRootPlayable( 0 ).GetPlayableType().Name != k_TypeName_TimlinePlayable ) | |
{ | |
Debug.LogError( $"{nameof(timelineGraph)}: {timelineGraph.GetEditorName()} doesn't appear to have been created by a TimelineAsset" ); | |
return; | |
} | |
Debug.Log( $"Logging info for PlayableGraph: {timelineGraph.GetEditorName()}" ); | |
int graphOutputCount = timelineGraph.GetOutputCount(); | |
for( int outputIndex = 0; outputIndex < graphOutputCount; ++outputIndex ) | |
{ | |
var output = timelineGraph.GetOutput( outputIndex ); | |
Debug.Assert( output.IsOutputValid() ); | |
var outputTrack = output.GetReferenceObject(); | |
var outputPlayable = output.GetSourcePlayable(); | |
if( outputTrack == null ) | |
{ | |
Debug.Log( $"output[{outputIndex}]: null output track - {outputPlayable.GetPlayableType()}" ); | |
} | |
else if( output.GetPlayableOutputType() == typeof(AnimationPlayableOutput) ) | |
{ | |
var target = ( (AnimationPlayableOutput)output ).GetTarget(); | |
Debug.Log( $"output[{outputIndex}]: {outputTrack.name} ({outputTrack.GetType().Name}({outputTrack.GetInstanceID()}))) - bound to: {( ( (bool)target ) ? target.name : "null" )}" ); | |
} | |
else if( output.GetPlayableOutputType() == typeof(AudioPlayableOutput) ) | |
{ | |
var target = ( (AudioPlayableOutput)output ).GetTarget(); | |
Debug.Log( $"output[{outputIndex}]: {outputTrack.name} ({outputTrack.GetType().Name}({outputTrack.GetInstanceID()}))) - bound to: {( ( (bool)target ) ? target.name : "null" )}" ); | |
} | |
else | |
{ | |
Debug.Assert( ( output.GetPlayableOutputType() == typeof(ScriptPlayableOutput) ) ); | |
var binding = output.GetUserData(); | |
var notes = string.Empty; | |
if( outputTrack.GetType().Name == k_TypeName_ControlTrack ) | |
{ | |
notes = "(each ControlTrack clip has its own binding; the track has none)"; | |
} | |
Debug.Log( $"output[{outputIndex}]: {outputTrack.name} ({outputTrack.GetType().Name}({outputTrack.GetInstanceID()}))) - bound to: {( ( (bool)binding ) ? binding.name : "null" )} {notes}" ); | |
} | |
} | |
} | |
} | |
////////////////////////////////////////////////////////////////////////////// | |
////////////////////////////////////////////////////////////////////////////// | |
public class PlayableGraphExposedReferenceResolver : IExposedPropertyTable | |
{ | |
private Dictionary< PropertyName, Object > m_propertyTable = new Dictionary< PropertyName, Object >( 256 ); | |
public void SetReferenceValue( PropertyName id, Object value ) => m_propertyTable[ id ] = value; | |
public Object GetReferenceValue( PropertyName id, out bool idValid ) | |
{ | |
idValid = m_propertyTable.TryGetValue( id, out var value ); | |
return idValid ? value : null; | |
} | |
public void ClearReferenceValue( PropertyName id ) => m_propertyTable.Remove( id ); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment