initial game submission

This commit is contained in:
Caleb Sandford deQuincey
2025-06-11 13:04:35 +01:00
parent 47944971ca
commit e9c9bc4409
3041 changed files with 328378 additions and 71117 deletions

View File

@@ -0,0 +1,526 @@
#define USE_ANIMATION_POINTER
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using GLTF.Schema;
using Unity.Profiling;
using UnityEngine;
using UnityGLTF.Plugins;
using Object = UnityEngine.Object;
namespace UnityGLTF.Timeline
{
public class GLTFRecorder
{
public GLTFRecorder(Transform root, bool recordBlendShapes = true, bool recordRootInWorldSpace = false, bool recordAnimationPointer = false)
{
if (!root)
throw new ArgumentNullException(nameof(root), "Please provide a root transform to record.");
this.root = root;
this.recordBlendShapes = recordBlendShapes;
this.recordRootInWorldSpace = recordRootInWorldSpace;
this.recordAnimationPointer = recordAnimationPointer;
}
/// <summary>
/// Optionally assign a list of transforms to be recorded, other transforms will be ignored
/// </summary>
internal ICollection<Transform> recordingList = null;
private bool AllowRecordingTransform(Transform tr) => recordingList == null || recordingList.Contains(tr);
private Transform root;
private Dictionary<Transform, AnimationData> data = new Dictionary<Transform, AnimationData>(64);
private double startTime;
private double lastRecordedTime;
private bool hasRecording;
private bool isRecording;
private readonly bool recordBlendShapes;
private readonly bool recordRootInWorldSpace;
private readonly bool recordAnimationPointer;
public bool HasRecording => hasRecording;
public bool IsRecording => isRecording;
public double LastRecordedTime => lastRecordedTime;
public string AnimationName = "Recording";
public delegate void OnBeforeAddAnimationDataDelegate(PostAnimationData animationData);
public delegate void OnPostExportDelegate(PostExportArgs animationData);
/// <summary>
/// Callback to modify the animation data before it is added to the animation.
/// Is called once for each track after the recording has ended. This is a non destructive callback,
/// so the original recorded data is not modified. Every time you call EndRecording to the save the gltf/glb,
/// you can modify the data again.
/// </summary>
public OnBeforeAddAnimationDataDelegate OnBeforeAddAnimationData;
/// <summary>
/// Callback to modify or add additional data to the gltf root after the recording has ended and animation
/// data is added to the animation.
/// </summary>
public OnPostExportDelegate OnPostExport;
public class PostExportArgs
{
public Bounds AnimationTranslationBounds { get; private set; }
public GLTFSceneExporter Exporter { get; private set; }
public GLTFRoot GltfRoot { get; private set; }
internal PostExportArgs(Bounds animationTranslationBounds, GLTFSceneExporter exporter, GLTFRoot gltfRoot)
{
this.AnimationTranslationBounds = animationTranslationBounds;
this.Exporter = exporter;
this.GltfRoot = gltfRoot;
}
}
public class PostAnimationData
{
internal AnimationData.Track animationTrack;
public float[] Times;
public object[] Values;
public Object AnimatedObject => animationTrack.animatedObject;
public string PropertyName => animationTrack.propertyName;
internal PostAnimationData(AnimationData.Track tr, float[] times, object[] values)
{
this.animationTrack = tr;
this.Times = times;
this.Values = values;
}
}
internal class AnimationData
{
internal Transform tr;
private SkinnedMeshRenderer smr;
private bool recordBlendShapes;
private bool inWorldSpace = false;
private bool recordAnimationPointer;
#if USE_ANIMATION_POINTER
private static List<ExportPlan> exportPlans;
private static MaterialPropertyBlock materialPropertyBlock = new MaterialPropertyBlock();
internal List<Track> tracks = new List<Track>();
internal class ExportPlan
{
public string propertyName;
public Type dataType;
public Func<Transform, Object> GetTarget;
public Func<Transform, Object, AnimationData, object> GetData;
public ExportPlan(string propertyName, Type dataType, Func<Transform, Object> GetTarget, Func<Transform, Object, AnimationData, object> GetData)
{
this.propertyName = propertyName;
this.dataType = dataType;
this.GetTarget = GetTarget;
this.GetData = GetData;
}
public object Sample(AnimationData data)
{
var target = GetTarget(data.tr);
return GetData(data.tr, target, data);
}
}
#endif
public AnimationData(Transform tr, double time, bool zeroScale = false, bool recordBlendShapes = true, bool inWorldSpace = false, bool recordAnimationPointer = false)
{
this.tr = tr;
this.smr = tr.GetComponent<SkinnedMeshRenderer>();
this.recordBlendShapes = recordBlendShapes;
this.inWorldSpace = inWorldSpace;
this.recordAnimationPointer = recordAnimationPointer;
if (exportPlans == null)
{
exportPlans = new List<ExportPlan>();
exportPlans.Add(new ExportPlan("translation", typeof(Vector3), x => x, (tr0, _, options) => options.inWorldSpace ? tr0.position : tr0.localPosition));
exportPlans.Add(new ExportPlan("rotation", typeof(Quaternion), x => x, (tr0, _, options) =>
{
var q = options.inWorldSpace ? tr0.rotation : tr0.localRotation;
return new Quaternion(q.x, q.y, q.z, q.w);
}));
exportPlans.Add(new ExportPlan("scale", typeof(Vector3), x => x, (tr0, _, options) => options.inWorldSpace ? tr0.lossyScale : tr0.localScale));
if (recordBlendShapes)
{
exportPlans.Add(new ExportPlan("weights", typeof(float[]),
x => x.GetComponent<SkinnedMeshRenderer>(), (tr0, x, options) =>
{
if (x is SkinnedMeshRenderer skinnedMesh && skinnedMesh.sharedMesh)
{
var mesh = skinnedMesh.sharedMesh;
var blendShapeCount = mesh.blendShapeCount;
if (blendShapeCount == 0) return null;
var weights = new float[blendShapeCount];
for (var i = 0; i < blendShapeCount; i++)
weights[i] = skinnedMesh.GetBlendShapeWeight(i);
return weights;
}
return null;
}));
}
if (recordAnimationPointer)
{
// TODO add other animation pointer export plans
exportPlans.Add(new ExportPlan("baseColorFactor", typeof(Color), x => x.GetComponent<MeshRenderer>() ? x.GetComponent<MeshRenderer>().sharedMaterial : null, (tr0, mat, options) =>
{
var r = tr0.GetComponent<Renderer>();
if (r.HasPropertyBlock())
{
r.GetPropertyBlock(materialPropertyBlock);
#if UNITY_2021_1_OR_NEWER
if (materialPropertyBlock.HasColor("_BaseColor")) return materialPropertyBlock.GetColor("_BaseColor").linear;
if (materialPropertyBlock.HasColor("_Color")) return materialPropertyBlock.GetColor("_Color").linear;
if (materialPropertyBlock.HasColor("baseColorFactor")) return materialPropertyBlock.GetColor("baseColorFactor").linear;
#else
var c = materialPropertyBlock.GetColor("_BaseColor");
if (c.r != 0 || c.g != 0 || c.b != 0 || c.a != 0) return c;
c = materialPropertyBlock.GetColor("_Color");
if (c.r != 0 || c.g != 0 || c.b != 0 || c.a != 0) return c;
// this leaves an edge case where someone is actually animating color to black:
// in that case, the un-animated color would now be exported...
#endif
}
if (mat is Material m && m)
{
if (m.HasProperty("_BaseColor")) return m.GetColor("_BaseColor").linear;
if (m.HasProperty("_Color")) return m.GetColor("_Color").linear;
if (m.HasProperty("baseColorFactor")) return m.GetColor("baseColorFactor").linear;
}
return null;
}));
}
}
foreach (var plan in exportPlans)
{
if (plan.GetTarget(tr)) {
tracks.Add(new Track(this, plan, time));
}
}
}
internal class Track
{
public Object animatedObject => plan.GetTarget(tr.tr);
public string propertyName => plan.propertyName;
// TODO sample as floats?
public float[] times => samples.Keys.Select(x => (float) x).ToArray();
public object[] values => samples.Values.ToArray();
private AnimationData tr;
private ExportPlan plan;
private Dictionary<double, object> samples;
class TimeSample
{
public double time;
public object value;
}
private TimeSample lastSample = null;
private TimeSample secondToLastSample = null;
public Track(AnimationData tr, ExportPlan plan, double time)
{
this.tr = tr;
this.plan = plan;
samples = new Dictionary<double, object>();
SampleIfChanged(time);
}
public void SampleIfChanged(double time)
{
var value = plan.Sample(tr);
if (value == null || (value is Object o && !o))
return;
// As a memory optimization we want to be able to skip identical samples.
// But, we cannot always skip samples when they are identical to the previous one - otherwise cases like this break:
// - First assume an object is invisible at first (by having a scale of (0,0,0))
// - At some point in time, it is instantaneously set "visible" by updating its scale from (0,0,0) to (1,1,1)
// If we simply skip identical samples on insert, instead of a instantaneous
// visibility/scale changes we get a linearly interpolated scale change because only two samples will be recorded:
// - one (0,0,0) at the start of time
// - (1,1,1) at the time of the visibility change
// What we want to get is
// - one sample with (0,0,0) at the start,
// - one with the same value right before the instantaneous change,
// - and then at the time of the change, we need a sample with (1,1,1)
// With this setup, now the linear interpolation only has an effect in the very short duration between the last two samples and we get the animation we want.
// How do we achieve both?
// Always sample & record and then on adding the next sample(s) we check
// if the *last two* samples were identical to the current sample.
// If that is the case we can remove/overwrite the middle sample with the new value.
if (lastSample != null
&& secondToLastSample != null
&& lastSample.value.Equals(secondToLastSample.value)
&& lastSample.value.Equals(value)) {
samples.Remove(lastSample.time);
}
samples[time] = value;
secondToLastSample = lastSample;
lastSample = new TimeSample { time = time, value = value};
}
}
public void Update(double time)
{
foreach (var track in tracks)
{
track.SampleIfChanged(time);
}
}
}
public void StartRecording(double time)
{
startTime = time;
lastRecordedTime = time;
var trs = root.GetComponentsInChildren<Transform>(true);
data.Clear();
foreach (var tr in trs)
{
if (!AllowRecordingTransform(tr)) continue;
data.Add(tr, new AnimationData(tr, 0, !tr.gameObject.activeSelf, recordBlendShapes, recordRootInWorldSpace && tr == root, recordAnimationPointer));
}
isRecording = true;
hasRecording = true;
}
public void UpdateRecording(double time)
{
if (!isRecording)
{
throw new InvalidOperationException($"{nameof(GLTFRecorder)} isn't recording, but {nameof(UpdateRecording)} was called. This is invalid.");
}
if (time <= lastRecordedTime)
{
Debug.LogWarning("Can't record backwards in time, please avoid this.");
return;
}
var currentTime = time - startTime;
var trs = root.GetComponentsInChildren<Transform>(true);
foreach (var tr in trs)
{
if (!AllowRecordingTransform(tr)) continue;
if (!data.ContainsKey(tr))
{
// insert "empty" frame with scale=0,0,0 because this object might have just appeared in this frame
var emptyData = new AnimationData(tr, lastRecordedTime, true, recordBlendShapes, recordAnimationPointer);
data.Add(tr, emptyData);
// insert actual keyframe
data[tr].Update(currentTime);
}
else
data[tr].Update(currentTime);
}
lastRecordedTime = time;
}
internal void EndRecording(out Dictionary<Transform, AnimationData> param)
{
param = null;
if (!hasRecording) return;
param = data;
}
public void EndRecording(string filename, string sceneName = "scene", GLTFSettings settings = null)
{
if (!isRecording) return;
if (!hasRecording) return;
var dir = Path.GetDirectoryName(filename);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
using (var filestream = new FileStream(filename, FileMode.Create, FileAccess.Write))
{
EndRecording(filestream, sceneName, settings);
}
}
public void EndRecording(Stream stream, string sceneName = "scene", GLTFSettings settings = null)
{
if (!hasRecording) return;
isRecording = false;
Debug.Log("Gltf Recording saved. Tracks: " + data.Count + ", Total Keyframes: " + data.Sum(x => x.Value.tracks.Sum(y => y.values.Count())));
if (!settings)
{
var adjustedSettings = Object.Instantiate(GLTFSettings.GetOrCreateSettings());
adjustedSettings.ExportDisabledGameObjects = true;
adjustedSettings.ExportAnimations = false;
settings = adjustedSettings;
}
// ensure correct animation pointer plugin settings are used
if (!recordAnimationPointer)
settings.ExportPlugins.RemoveAll(x => x is AnimationPointerExport);
else
if (!settings.ExportPlugins.Any(x => x is AnimationPointerExport))
settings.ExportPlugins.Add(ScriptableObject.CreateInstance<AnimationPointerExport>());
if (!recordBlendShapes)
settings.BlendShapeExportProperties = GLTFSettings.BlendShapeExportPropertyFlags.None;
var logHandler = new StringBuilderLogHandler();
var exporter = new GLTFSceneExporter(new Transform[] { root }, new ExportContext(settings)
{
AfterSceneExport = PostExport,
logger = new Logger(logHandler),
});
exporter.SaveGLBToStream(stream, sceneName);
logHandler.LogAndClear();
}
private void PostExport(GLTFSceneExporter exporter, GLTFRoot gltfRoot)
{
// this would include animation from the original root
// exporter.ExportAnimationFromNode(ref root);
GLTFAnimation anim = new GLTFAnimation();
anim.Name = AnimationName;
CollectAndProcessAnimation(exporter, anim, true, out Bounds translationBounds);
if (anim.Channels.Count > 0 && anim.Samplers.Count > 0)
gltfRoot.Animations.Add(anim);
OnPostExport?.Invoke( new PostExportArgs(translationBounds, exporter, gltfRoot));
}
private static void SafeDestroy(Object obj)
{
if (Application.isEditor)
Object.DestroyImmediate(obj);
else
Object.Destroy(obj);
}
private static ProfilerMarker processAnimationMarker = new ProfilerMarker("Process Animation");
private static ProfilerMarker simplifyKeyframesMarker = new ProfilerMarker("Simplify Keyframes");
private static ProfilerMarker convertValuesMarker = new ProfilerMarker("Convert Values to Arrays");
private void CollectAndProcessAnimation(GLTFSceneExporter gltfSceneExporter, GLTFAnimation anim, bool calculateTranslationBounds, out Bounds translationBounds)
{
var gotFirstValue = false;
translationBounds = new Bounds();
foreach (var kvp in data)
{
processAnimationMarker.Begin();
foreach (var tr in kvp.Value.tracks)
{
if (tr.times.Length == 0) continue;
var postAnimation = new PostAnimationData(tr, tr.times, tr.values);
OnBeforeAddAnimationData?.Invoke(postAnimation);
if (calculateTranslationBounds && tr.propertyName == "translation")
{
for (var i = 0; i < postAnimation.Values.Length; i++)
{
var vec = (Vector3) postAnimation.Values[i];
if (!gotFirstValue)
{
translationBounds = new Bounds(vec, Vector3.zero);
gotFirstValue = true;
}
else
{
translationBounds.Encapsulate(vec);
}
}
}
gltfSceneExporter.RemoveUnneededKeyframes(ref postAnimation.Times, ref postAnimation.Values);
gltfSceneExporter.AddAnimationData(tr.animatedObject, tr.propertyName, anim, postAnimation.Times, postAnimation.Values);
}
processAnimationMarker.End();
}
}
private class StringBuilderLogHandler : ILogHandler
{
private readonly StringBuilder sb = new StringBuilder();
private string LogTypeToLog(LogType logType)
{
#if UNITY_EDITOR
// create strings with <color> tags
switch (logType)
{
case LogType.Error:
return "<color=red>[" + logType + "]</color>";
case LogType.Assert:
return "<color=red>[" + logType + "]</color>";
case LogType.Warning:
return "<color=yellow>[" + logType + "]</color>";
case LogType.Log:
return "[" + logType + "]";
case LogType.Exception:
return "<color=red>[" + logType + "]</color>";
default:
return "[" + logType + "]";
}
#else
return "[" + logType + "]";
#endif
}
public void LogFormat(LogType logType, Object context, string format, params object[] args) => sb.AppendLine($"{LogTypeToLog(logType)} {string.Format(format, args)} [Context: {context}]");
public void LogException(Exception exception, Object context) => sb.AppendLine($"{LogTypeToLog(LogType.Exception)} {exception} [Context: {context}]");
public void LogAndClear()
{
if(sb.Length > 0)
{
var str = sb.ToString();
#if UNITY_2019_1_OR_NEWER
var logType = LogType.Log;
#if UNITY_EDITOR
if (str.IndexOf("[Error]", StringComparison.Ordinal) > -1 ||
str.IndexOf("[Exception]", StringComparison.Ordinal) > -1 ||
str.IndexOf("[Assert]", StringComparison.Ordinal) > -1)
logType = LogType.Error;
else if (str.IndexOf("[Warning]", StringComparison.Ordinal) > -1)
logType = LogType.Warning;
#endif
Debug.LogFormat(logType, LogOption.NoStacktrace, null, "Export Messages:" + "\n{0}", sb.ToString());
#else
Debug.Log(string.Format("Export Messages:" + "\n{0}", str));
#endif
}
sb.Clear();
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5c86ab342c27009409e8735873d51306
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,111 @@
#if HAVE_TIMELINE
using UnityEngine;
using UnityEngine.Playables;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace UnityGLTF.Timeline
{
public class GLTFRecorderBehaviour : PlayableBehaviour
{
private GLTFRecorder recorder = null;
private void BeginRecording(double getTime, Transform getExportRoot)
{
if (!getExportRoot)
{
Debug.LogError("Can't record: export root is null");
recorder = null;
return;
}
Time.captureFramerate = Clip.m_CaptureFrameRate;
// will be initialized lazy to ensure that the first frame is correct and not pre-timeline refresh
recorder = null;
}
private void StopRecording(double getTime)
{
recorder?.EndRecording(Clip.m_File);
}
private void ProcessRecording(double getTime, Transform getExportRoot)
{
if (recorder == null)
{
recorder = new GLTFRecorder(getExportRoot, Clip.m_RecordBlendShapes, recordAnimationPointer: Clip.m_RecordAnimationPointer);
recorder.AnimationName = Clip.m_AnimationName;
recorder.StartRecording(getTime);
}
else if (getTime > recorder.LastRecordedTime)
{
recorder?.UpdateRecording(getTime);
}
}
public GLTFRecorderClip Clip;
private bool m_isPaused = false;
private static bool IsPlaying()
{
#if UNITY_EDITOR
return EditorApplication.isPlaying;
#else
return true;
#endif
}
public override void OnPlayableDestroy(Playable playable)
{
if (!IsPlaying()) return;
StopRecording(playable.GetTime());
}
public override void OnGraphStart(Playable playable)
{
if (!IsPlaying()) return;
BeginRecording(playable.GetTime(), Clip.GetExportRoot(playable.GetGraph()));
}
public override void OnGraphStop(Playable playable)
{
if (!IsPlaying()) return;
StopRecording(playable.GetTime());
}
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
if (!IsPlaying()) return;
double time = playable.GetTime();
GLTFRecorderHelper.Add(() => OnFrameEnd(time, playable, info, playerData));
}
public override void OnBehaviourPlay(Playable playable, FrameData info)
{
m_isPaused = false;
}
public override void OnBehaviourPause(Playable playable, FrameData info)
{
m_isPaused = true;
}
public void OnFrameEnd(double time, Playable playable, FrameData info, object playerData)
{
if (!playable.IsValid()) return;
var root = Clip.GetExportRoot(playable.GetGraph());
if (!root || m_isPaused) return;
ProcessRecording(time, root);
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5c90c158375298a4b931f2f013dbf111
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,37 @@
#if HAVE_TIMELINE
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
namespace UnityGLTF.Timeline
{
[System.ComponentModel.DisplayName("glTF Recorder Clip")]
public class GLTFRecorderClip : PlayableAsset, ITimelineClipAsset
{
public ExposedReference<Transform> m_exportRoot;
public string m_File = "Assets/Recording.glb";
public string m_AnimationName = "Recording";
public int m_CaptureFrameRate = 60;
public bool m_RecordBlendShapes = true;
[Tooltip("Enable the KHR_animation_pointer extension. This allows recording material and script properties, but is not supported in all viewers.")]
public bool m_RecordAnimationPointer = false;
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
{
var ret = ScriptPlayable<GLTFRecorderBehaviour>.Create(graph);
var behaviour = ret.GetBehaviour();
behaviour.Clip = this; // TOD check if needed
return ret;
}
public ClipCaps clipCaps { get; }
public Transform GetExportRoot(PlayableGraph graph)
{
return m_exportRoot.Resolve(graph.GetResolver());
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a7ec2229f5635a54cbc23eec87b45654
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace UnityGLTF.Timeline
{
[ExecuteInEditMode]
internal class GLTFRecorderHelper : MonoBehaviour
{
public static void Add(Action callback)
{
GetInstance().queuedActions.Add(callback);
}
private List<Action> queuedActions = new List<Action>();
private static GLTFRecorderHelper instance;
static GLTFRecorderHelper GetInstance()
{
if (instance != null) return instance;
#if UNITY_2023_1_OR_NEWER
instance = FindFirstObjectByType<GLTFRecorderHelper>();
#else
instance = FindObjectOfType<GLTFRecorderHelper>();
#endif
if (instance != null) return instance;
var go = new GameObject
{
name = "GLTF Recorder Helper",
hideFlags = HideFlags.HideAndDontSave,
};
instance = go.AddComponent<GLTFRecorderHelper>();
return instance;
}
private IEnumerator WaitForEndOfFrame()
{
yield return new WaitForEndOfFrame();
foreach (var callback in queuedActions)
{
try
{
callback();
}
catch (Exception ex)
{
Debug.LogException(ex);
}
}
queuedActions.Clear();
}
private void LateUpdate()
{
StartCoroutine(WaitForEndOfFrame());
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fba5af8565ac464587d0d4663f151239
timeCreated: 1646945202

View File

@@ -0,0 +1,16 @@
#if HAVE_TIMELINE
using UnityEngine.Timeline;
namespace UnityGLTF.Timeline
{
[System.Serializable]
[TrackClipType(typeof(GLTFRecorderClip))]
[TrackColor(0.7f, 0.0f, 0.0f)]
public class GLTFRecorderTrack : TrackAsset
{
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5f4885dbb1716a34b84ab7851fb37ccd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: