Files
2025-11-30 08:35:03 +02:00

428 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Reflection;
using Needle.Engine.Core;
using Needle.Engine.Gltf;
using Needle.Engine.Interfaces;
using Needle.Engine.Problems;
using Needle.Engine.Utils;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
using UnityGLTF.Extensions;
using Object = UnityEngine.Object;
namespace Needle.Engine.Timeline
{
public class PlayableDirectorExportContext
{
public PlayableDirector Director;
public IExportContext ExportContext;
public TrackExportContext CurrentTrack;
/// <summary>
/// Key is either a TimelineClip or an AnimationClip if the track is in infinite mode
/// </summary>
public readonly Dictionary<object, int> ClipMap = new Dictionary<object, int>();
public PlayableDirectorExportContext(PlayableDirector director, IExportContext exportContext)
{
Director = director;
ExportContext = exportContext;
}
}
public class TrackExportContext
{
public readonly List<Object> Bindings = new List<Object>();
}
public static class TimelineSerializer
{
public delegate void CreateModelEvent(object asset, ref object model);
public static event CreateModelEvent CreateModel;
public const string ExportMutedAssetLabel = "ExportMuted";
/// <summary>
/// Called before a track is added to the serialization model. Useful to e.g. control a tracks mute state to be added to the model or not (if a track is muted it is not added by default
/// </summary>
public static event Action<PlayableDirector, TrackAsset> BeforeAddTrack;
/// <summary>
/// Director, Asset, Bool if it was added
/// </summary>
public static event Action<PlayableDirector, TrackAsset, bool> AfterAddTrack;
public static bool TryExportPlayableAsset(PlayableDirectorExportContext context, TimelineAsset asset, out object result)
{
// evaluate the timeline once to make sure the data is up to date
// this fixes an issue we had for one timeline when building the website
// where exported clips/offset/... were not correct and the timeline had
// always to be opened once in the timeline window to be exported correctly.
if (context.Director)
{
TimelineUtils.EvaluateTimeline(context.Director);
}
var tags = AssetDatabase.GetLabels(asset);
var ignoreMuted = true;
foreach (var tag in tags)
{
if(tag.Equals(ExportMutedAssetLabel, StringComparison.OrdinalIgnoreCase)) ignoreMuted = false;
}
// const int k_maxStatesAfterTrial = 2;
// var trialHasEnded = NeedleEngineAuthorization.TrialEnded;
// var isInTrial = NeedleEngineAuthorization.IsInTrialPeriod;
// var exportedTracks = 0;
var exp = new TimelineAssetModel();
exp.name = asset.name;
exp.guid = asset.GetId();
var outputTracks = asset.GetOutputTracks().ToArray();
// if (outputTracks.Length > k_maxStatesAfterTrial && !LicenseCheck.HasLicense && isInTrial)
// {
// Debug.LogWarning(
// $"Timeline \"{asset.name}\" has more than {k_maxStatesAfterTrial} tracks. Please upgrade to an Indie or Pro Plan of Needle to export more than {k_maxStatesAfterTrial} tracks after your trial has ended.", asset);
// }
foreach (var track in outputTracks)
{
// if (LicenseCheck.HasLicense == false)
// {
// if (exportedTracks++ >= k_maxStatesAfterTrial && trialHasEnded)
// {
// Debug.LogWarning(
// $"Timeline \"{asset.name}\" has more than {k_maxStatesAfterTrial} tracks. Please upgrade to an Indie or Pro Plan of Needle to export more than {k_maxStatesAfterTrial} timeline tracks.");
// var info = new BuildResultInformation($"Timeline \"{asset.name}\" has {outputTracks.Length} tracks", asset, ProblemSeverity.Error);
// info.ActionDescription = "<b>Purchase a commercial license</b> or reduce to 2 tracks";
// BuildResultInformation.Report(info);
// break;
// }
// }
BeforeAddTrack?.Invoke(context.Director, track);
var shouldAdd = !ignoreMuted || track.mutedInHierarchy == false;
if (shouldAdd)
{
AddTrack(exp, context.Director, track, context);
}
AfterAddTrack?.Invoke(context.Director, track, shouldAdd);
}
result = exp;
return true;
}
private static bool _triedGettingSceneOffsetProperties;
private static PropertyInfo _sceneOffsetPositionProp, _sceneOffsetRotationProp;
private static Quaternion _sceneOffsetRotationTempQuat = new Quaternion();
public static void AddTrack(TimelineAssetModel timelineAssetModel,
PlayableDirector dir,
TrackAsset track,
PlayableDirectorExportContext context)
{
var tr = new TimelineTrackModel();
tr.name = track.name;
tr.type = track.GetType().Name;
tr.muted = track.muted;
context.CurrentTrack ??= new TrackExportContext();
var trackContext = context.CurrentTrack;
trackContext.Bindings.Clear();
foreach (var output in track.outputs)
{
var binding = dir.GetGenericBinding(output.sourceObject);
if (binding)
{
trackContext.Bindings.Add(binding);
tr.outputs.Add(binding.GetId());
}
else tr.outputs.Add(null);
}
timelineAssetModel.tracks.Add(tr);
if (track is AnimationTrack animationTrack)
{
tr.trackOffset = new TrackOffset();
switch (animationTrack.trackOffset)
{
case UnityEngine.Timeline.TrackOffset.ApplySceneOffsets:
if (_sceneOffsetPositionProp == null)
{
if (_triedGettingSceneOffsetProperties) break;
_triedGettingSceneOffsetProperties = true;
_sceneOffsetPositionProp =
typeof(AnimationTrack).GetProperty("sceneOffsetPosition", BindingFlags.Instance | BindingFlags.NonPublic);
_sceneOffsetRotationProp = typeof(AnimationTrack).GetProperty("sceneOffsetRotation", BindingFlags.Instance | BindingFlags.NonPublic);
if(_sceneOffsetPositionProp == null || _sceneOffsetRotationProp == null)
break;
}
tr.trackOffset.position = new Vec3((Vector3)_sceneOffsetPositionProp.GetValue(animationTrack));
var rot = (Vector3)_sceneOffsetRotationProp.GetValue(animationTrack);
_sceneOffsetRotationTempQuat.eulerAngles = rot;
tr.trackOffset.rotation = new Quat(_sceneOffsetRotationTempQuat.ToGltfQuaternionConvert());
break;
case UnityEngine.Timeline.TrackOffset.Auto:
case UnityEngine.Timeline.TrackOffset.ApplyTransformOffsets:
tr.trackOffset.position = new Vec3(animationTrack.position);
tr.trackOffset.rotation = new Quat(animationTrack.rotation.ToGltfQuaternionConvert());
break;
}
if (animationTrack.inClipMode == false && animationTrack.infiniteClip)
{
var cl = new TimelineClipModel();
cl.start = animationTrack.start;
cl.end = animationTrack.end;
cl.duration = animationTrack.duration;
cl.timeScale = 1;
CreateAnimationClipModel(context, tr, cl,
TimelineAnimationClipInfo.CreateFromInfiniteTrack(animationTrack),
animationTrack.infiniteClip, animationTrack.infiniteClip);
cl.preExtrapolationMode = (int)TimelineClip.ClipExtrapolation.Hold;
cl.postExtrapolationMode = (int)TimelineClip.ClipExtrapolation.Hold;
tr.clips.Add(cl);
}
}
else if (track is AudioTrack audioTrack)
{
tr.volume = 1;
if (TryGetAudioTrackVolume(audioTrack, out var vol))
{
tr.volume = vol;
}
}
foreach (var clip in track.GetClips()) AddClip(tr, clip, context);
foreach (var marker in track.GetMarkers()) AddMarker(tr, marker, context);
}
private static readonly Dictionary<Type, FieldInfo[]> fieldsForType = new Dictionary<Type, FieldInfo[]>();
public static void AddClip(TimelineTrackModel timelineTrackModel, TimelineClip clip, PlayableDirectorExportContext context)
{
var cl = new TimelineClipModel();
cl.start = clip.start;
cl.end = clip.end;
cl.duration = clip.duration;
cl.timeScale = clip.timeScale;
cl.clipIn = clip.clipIn;
cl.easeInDuration = clip.blendInDuration >= 0 ? clip.blendInDuration : clip.easeInDuration;
cl.easeOutDuration = clip.blendOutDuration >= 0 ? clip.blendOutDuration : clip.easeOutDuration;
cl.preExtrapolationMode = (int)clip.preExtrapolationMode;
cl.postExtrapolationMode = (int)clip.postExtrapolationMode;
timelineTrackModel.clips.Add(cl);
switch (clip.asset)
{
default:
if (clip.asset)
{
CreateModel?.Invoke(clip.asset, ref cl.asset);
if (cl.asset == null)
{
// automatically create a model when none is provided via callback
var assetType = clip.asset.GetType();
var serializable = typeof(SerializeField);
var obj = new ExpandoObject() as IDictionary<string, object>;
cl.asset = obj;
if (!fieldsForType.TryGetValue(assetType, out var fields))
{
fields = assetType.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
fieldsForType.Add(assetType, fields);
}
foreach (var type in fields)
{
if (type.GetCustomAttribute(serializable) != null)
{
obj.Add(type.Name, type.GetValue(clip.asset));
}
}
}
}
break;
case ControlPlayableAsset control:
var target = control.sourceGameObject.Resolve(context.Director);
if (target)
{
var controlModel = new ControlClipModel();
controlModel.sourceObject = target.GetId();
controlModel.controlActivation = control.active;
controlModel.updateDirector = control.updateDirector;
cl.asset = controlModel;
}
break;
case AudioPlayableAsset audio:
var audioClip = audio.clip;
if (audioClip)
{
var model = new AudioCLipModel();
model.loop = audio.loop;
model.clip = audioClip;
if(TryGetAudioClipVolume(clip, audio, out var volume))
model.volume = volume;
cl.asset = model;
}
else
{
// dont export audio clip track with missing clip
timelineTrackModel.clips.Remove(cl);
}
break;
case AnimationPlayableAsset anim:
CreateAnimationClipModel(context, timelineTrackModel, cl,
TimelineAnimationClipInfo.CreateFromAsset(anim), anim.clip, clip);
break;
}
}
private static readonly FieldInfo audioClipPropertiesField =
typeof(AudioPlayableAsset).GetField("m_ClipProperties", BindingFlags.Instance | BindingFlags.NonPublic);
private static FieldInfo audioClipPropertiesVolumeField;
private static bool TryGetAudioClipVolume(TimelineClip clip, AudioPlayableAsset audio, out float volume)
{
if (audioClipPropertiesField != null)
{
var props = audioClipPropertiesField.GetValue(audio);
if (props != null)
{
audioClipPropertiesVolumeField ??=
props.GetType().GetField("volume", BindingFlags.Instance | BindingFlags.Public);
var res = audioClipPropertiesVolumeField?.GetValue(props);
if (res is float fl)
{
volume = fl;
return true;
}
}
}
volume = 0;
return false;
}
private static readonly FieldInfo audioTrackPropertiesField =
typeof(AudioTrack).GetField("m_TrackProperties", BindingFlags.Instance | BindingFlags.NonPublic);
private static FieldInfo audioMixerVolumeField;
private static bool TryGetAudioTrackVolume(AudioTrack track, out float volume)
{
if (audioTrackPropertiesField != null)
{
var props = audioTrackPropertiesField.GetValue(track);
if (props != null)
{
audioMixerVolumeField ??=
props.GetType().GetField("volume", BindingFlags.Instance | BindingFlags.Public);
var res = audioMixerVolumeField?.GetValue(props);
if (res is float fl)
{
volume = fl;
return true;
}
}
}
volume = 0;
return false;
}
private struct TimelineAnimationClipInfo
{
public string name;
public bool removeStartOffset;
public Vector3 position;
public Quaternion rotation;
public AnimationPlayableAsset.LoopMode loop;
public static TimelineAnimationClipInfo CreateFromInfiniteTrack(AnimationTrack track)
{
Debug.Assert(track.inClipMode == false);
Debug.Assert(track.infiniteClip);
var useOffset = track.infiniteClip?.UseOffsets() ?? true;
return new TimelineAnimationClipInfo()
{
name = track.name,
removeStartOffset = false,
position = useOffset ? track.infiniteClipOffsetPosition : Vector3.zero,
rotation = useOffset ? track.infiniteClipOffsetRotation : Quaternion.identity
};
}
public static TimelineAnimationClipInfo CreateFromAsset(AnimationPlayableAsset asset)
{
var useOffset = asset.clip?.UseOffsets() ?? true;
return new TimelineAnimationClipInfo()
{
removeStartOffset = asset.removeStartOffset,
position = useOffset ? asset.position : Vector3.zero,
rotation = useOffset ? asset.rotation : Quaternion.identity,
loop = asset.loop,
name = asset.name
};
}
}
private static AnimationClipModel CreateAnimationClipModel(PlayableDirectorExportContext context,
TimelineTrackModel timelineTrackModel,
TimelineClipModel cl,
TimelineAnimationClipInfo anim,
AnimationClip clip,
object key)
{
if (!clip) return null;
if (context.ClipMap.TryGetValue(key, out var id))
{
var animClipModel = new AnimationClipModel();
cl.asset = animClipModel;
animClipModel.clip = id.AsAnimationPointer();
animClipModel.duration = clip.length;
animClipModel.removeStartOffset = anim.removeStartOffset;
animClipModel.position = new Vec3(anim.position);
animClipModel.position.x *= -1;
// (animClipModel.position.x, animClipModel.position.z) = (animClipModel.position.z, animClipModel.position.x);
animClipModel.rotation = new Quat(anim.rotation.ToGltfQuaternionConvert());
switch (anim.loop)
{
default:
case AnimationPlayableAsset.LoopMode.UseSourceAsset:
animClipModel.loop = clip.isLooping;
break;
case AnimationPlayableAsset.LoopMode.On:
animClipModel.loop = true;
break;
case AnimationPlayableAsset.LoopMode.Off:
animClipModel.loop = false;
break;
}
return animClipModel;
}
var hasAnyOutput = timelineTrackModel.outputs.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s)) != null;
if (hasAnyOutput)
Debug.LogWarning(
$"Some animation clips could not be found/exported for \"{anim.name}/{clip.name}\". The objects might be disabled or the clips may need KHR_animation_pointer support.",
clip);
return null;
}
public static void AddMarker(TimelineTrackModel timelineTrackModel, IMarker marker, PlayableDirectorExportContext context)
{
switch (marker)
{
case SignalEmitter emitter:
var model = new TimelineSignalEmitterMarkerModel();
model.name = emitter.name;
model.type = nameof(SignalEmitter);
model.time = (float)emitter.time;
model.retroActive = emitter.retroactive;
model.emitOnce = emitter.emitOnce;
model.asset = emitter.asset.GetId();
timelineTrackModel.markers.Add(model);
break;
}
}
}
}