using System.Collections.Generic; using UnityEditor; using UnityEngine; namespace Yum.Fold { public static class FoldKeyframes { public struct Field { public string name; public float value; public bool discrete; public Field(string name, float value, bool discrete = false) { this.name = name; this.value = value; this.discrete = discrete; } } // Unity 2022.3 discrete integer curves store integer bits in the float key value. // Numeric conversion would play opcode 12 as 1094713344. [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)] struct DiscreteValue { [System.Runtime.InteropServices.FieldOffset(0)] public int integer; [System.Runtime.InteropServices.FieldOffset(0)] public float floating; } static float CurveValue(Field field) => field.name == "opcode" ? new DiscreteValue { integer = Mathf.RoundToInt(field.value) }.floating : field.value; public static List Fields(FoldOperator op) { var fields = new List { new Field("enabled", op.enabled ? 1 : 0, true), new Field("opcode", op.opcode, true), new Field("float0", op.float0), new Field("float1", op.float1), new Field("float2", op.float2), new Field("float3", op.float3) }; var vectors = new[] { op.vector0, op.vector1, op.vector2, op.vector3 }; for (int i = 0; i < 4; i++) for (int c = 0; c < 4; c++) fields.Add(new Field("vector" + i + "." + "xyzw"[c], vectors[i][c])); return fields; } public static string Prefix(int material, int slot) => "slot" + slot + "_"; static EditorCurveBinding Binding(string path, string prefix, Field field) => field.name == "opcode" ? EditorCurveBinding.DiscreteCurve(path, typeof(FoldMaterialAnimator), prefix + field.name) : EditorCurveBinding.FloatCurve(path, typeof(FoldMaterialAnimator), prefix + field.name); // Compare actual preceding keys, not interpolated values or transient window state. // Also repair partial operator keys made by the Animation window's record mode. public static bool Changed(AnimationClip clip, string path, string prefix, List fields, float time) { float latest = float.NegativeInfinity; var times = new List(); foreach (var field in fields) { var curve = AnimationUtility.GetEditorCurve(clip, Binding(path, prefix, field)); bool found = false; float keyTime = float.NegativeInfinity; float value = 0; if (curve != null) foreach (var key in curve.keys) if (key.time <= time + .00001f) { found = true; keyTime = key.time; value = key.value; } if (!found || !value.Equals(CurveValue(field))) return true; times.Add(keyTime); latest = Mathf.Max(latest, keyTime); } foreach (float keyTime in times) if (Mathf.Abs(keyTime - latest) > .00001f) return true; return false; } // Clear the current frame across every track in the clip, including object references. public static int Clear(AnimationClip clip, float time) { int frame = Mathf.RoundToInt(Mathf.Max(0, time) * clip.frameRate); var bindings = new List(); var curves = new List(); var objectBindings = new List(); var objectCurves = new List(); int count = 0; foreach (var binding in AnimationUtility.GetCurveBindings(clip)) { var curve = AnimationUtility.GetEditorCurve(clip, binding); int before = curve.length; for (int i = curve.length - 1; i >= 0; i--) if (Mathf.RoundToInt(curve[i].time * clip.frameRate) == frame) curve.RemoveKey(i); if (curve.length == before) continue; count += before - curve.length; bindings.Add(binding); curves.Add(curve.length == 0 ? null : curve); } foreach (var binding in AnimationUtility.GetObjectReferenceCurveBindings(clip)) { var keys = AnimationUtility.GetObjectReferenceCurve(clip, binding); var remaining = new List(); foreach (var key in keys) if (Mathf.RoundToInt(key.time * clip.frameRate) != frame) remaining.Add(key); if (remaining.Count == keys.Length) continue; count += keys.Length - remaining.Count; objectBindings.Add(binding); objectCurves.Add(remaining.Count == 0 ? null : remaining.ToArray()); } if (count == 0) return 0; Undo.RegisterCompleteObjectUndo(clip, "Clear keyframe"); AnimationUtility.SetEditorCurves(clip, bindings.ToArray(), curves.ToArray()); for (int i = 0; i < objectBindings.Count; i++) AnimationUtility.SetObjectReferenceCurve(clip, objectBindings[i], objectCurves[i]); EditorUtility.SetDirty(clip); return count; } // Convert only complete linear numeric spans. Tangents belong to a side of // a key: a handoff can keep its stepped incoming side and ease out normally. public static int EaseLinearSpans(AnimationClip clip) { var numeric = new HashSet(); foreach (var field in Fields(new FoldOperator())) if (!field.discrete) for (int slot = 0; slot < 16; slot++) numeric.Add(Prefix(0, slot) + field.name); var bindings = new List(); var curves = new List(); int count = 0; foreach (var binding in AnimationUtility.GetCurveBindings(clip)) { if (binding.type != typeof(FoldMaterialAnimator) || binding.isDiscreteCurve || !numeric.Contains(binding.propertyName)) continue; var curve = AnimationUtility.GetEditorCurve(clip, binding); bool changed = false; for (int i = 0; i + 1 < curve.length; i++) { if (curve[i].value.Equals(curve[i + 1].value) || AnimationUtility.GetKeyRightTangentMode(curve, i) != AnimationUtility.TangentMode.Linear || AnimationUtility.GetKeyLeftTangentMode(curve, i + 1) != AnimationUtility.TangentMode.Linear || float.IsInfinity(curve[i].outTangent) || float.IsInfinity(curve[i + 1].inTangent)) continue; FlattenSide(curve, i, false); FlattenSide(curve, i + 1, true); changed = true; count++; } if (changed) { bindings.Add(binding); curves.Add(curve); } } if (count > 0) { Undo.RegisterCompleteObjectUndo(clip, "Ease Fold animation"); AnimationUtility.SetEditorCurves(clip, bindings.ToArray(), curves.ToArray()); EditorUtility.SetDirty(clip); } return count; } static void FlattenSide(AnimationCurve curve, int index, bool incoming) { AnimationUtility.SetKeyBroken(curve, index, true); if (incoming) AnimationUtility.SetKeyLeftTangentMode(curve, index, AnimationUtility.TangentMode.Free); else AnimationUtility.SetKeyRightTangentMode(curve, index, AnimationUtility.TangentMode.Free); var key = curve[index]; if (incoming) { key.inTangent = 0; key.weightedMode &= ~WeightedMode.In; } else { key.outTangent = 0; key.weightedMode &= ~WeightedMode.Out; } curve.MoveKey(index, key); } static float PreviousAppearanceTime(AnimationClip clip, float time) { float previous = float.NegativeInfinity; foreach (var binding in AnimationUtility.GetCurveBindings(clip)) { if (binding.type != typeof(FoldMaterialAnimator) || !binding.propertyName.StartsWith("slot")) continue; foreach (var key in AnimationUtility.GetEditorCurve(clip, binding).keys) if (key.time < time - .00001f) previous = Mathf.Max(previous, key.time); } return previous; } static void Key(AnimationCurve curve, float time, float value, bool discrete) { var left = discrete ? AnimationUtility.TangentMode.Constant : AnimationUtility.TangentMode.Linear; var right = left; for (int i = curve.length - 1; i >= 0; i--) if (Mathf.Abs(curve[i].time - time) < .00001f) { // Re-keying a pose must not erase an existing stepped reorder boundary. if (AnimationUtility.GetKeyLeftTangentMode(curve, i) == AnimationUtility.TangentMode.Constant) left = AnimationUtility.TangentMode.Constant; if (AnimationUtility.GetKeyRightTangentMode(curve, i) == AnimationUtility.TangentMode.Constant) right = AnimationUtility.TangentMode.Constant; curve.RemoveKey(i); } int index = curve.AddKey(new Keyframe(time, value)); AnimationUtility.SetKeyBroken(curve, index, left != right); AnimationUtility.SetKeyLeftTangentMode(curve, index, left); AnimationUtility.SetKeyRightTangentMode(curve, index, right); // Refresh neighboring cached slopes after insertion while preserving their tangent modes. if (index > 0) AnimationUtility.SetKeyRightTangentMode(curve, index - 1, AnimationUtility.GetKeyRightTangentMode(curve, index - 1)); if (index + 1 < curve.length) AnimationUtility.SetKeyLeftTangentMode(curve, index + 1, AnimationUtility.GetKeyLeftTangentMode(curve, index + 1)); } static void HoldPreviousState(AnimationCurve curve, float time, bool discrete) { float value = 0; foreach (var key in curve.keys) { // Keep existing keys and their tangents. Never sample interpolation towards a future pose. if (Mathf.Abs(key.time - time) < .00001f) return; if (key.time > time) break; value = key.value; } Key(curve, time, value, discrete); } static float LastValue(AnimationCurve curve, float time) { float value = 0; if (curve != null) foreach (var key in curve.keys) { if (key.time > time + .00001f) break; value = key.value; } return value; } // Snapshot all sources before writing destination curves. A swap must read the // original state on both sides, never the state just written into the first slot. static float[][] PreviousStates(AnimationClip clip, string path, float time) { var fields = Fields(new FoldOperator()); var states = new float[16][]; for (int slot = 0; slot < states.Length; slot++) { states[slot] = new float[fields.Count]; for (int f = 0; f < fields.Count; f++) states[slot][f] = LastValue(AnimationUtility.GetEditorCurve(clip, Binding(path, Prefix(0, slot), fields[f])), time); } return states; } static bool SameState(float[] previous, List current) { for (int f = 0; f < current.Count; f++) if (!previous[f].Equals(CurveValue(current[f]))) return false; return true; } static int[] ReorderSources(FoldMaterialAnimator material, float[][] previous, out bool[] affected) { var sources = new int[16]; var used = new bool[16]; var oldOpcodes = new int[16]; var current = new List[16]; affected = new bool[16]; for (int slot = 0; slot < 16; slot++) { sources[slot] = -1; oldOpcodes[slot] = new DiscreteValue { floating = previous[slot][1] }.integer; current[slot] = Fields(material.slots[slot]); } // Preserve the longest unchanged sequence first. A newly inserted operator // can equal an existing one farther down the stack; greedy matching would // steal that source and reset the real survivor to its jumpback defaults. var matches = new bool[16, 16]; var lengths = new int[17, 17]; for (int source = 15; source >= 0; source--) for (int destination = 15; destination >= 0; destination--) { matches[source, destination] = oldOpcodes[source] != 0 && SameState(previous[source], current[destination]); lengths[source, destination] = matches[source, destination] ? 1 + lengths[source + 1, destination + 1] : System.Math.Max(lengths[source + 1, destination], lengths[source, destination + 1]); } for (int source = 0, destination = 0; source < 16 && destination < 16;) { if (matches[source, destination]) { sources[destination] = source; used[source] = true; source++; destination++; } else if (lengths[source + 1, destination] > lengths[source, destination + 1]) source++; else destination++; } // Match the remaining exact states to support arbitrary reorders too. for (int destination = 0; destination < 16; destination++) { if (sources[destination] >= 0 || current[destination][1].value == 0) continue; for (int source = 0; source < 16; source++) if (!used[source] && SameState(previous[source], current[destination])) { sources[destination] = source; used[source] = true; break; } } // Remaining same-slot operators may have ordinary parameter edits. for (int slot = 0; slot < 16; slot++) if (sources[slot] < 0 && !used[slot] && oldOpcodes[slot] == current[slot][1].value) { sources[slot] = slot; used[slot] = true; } // A moved operator may also have been edited. Match by opcode only when // both its remaining source and destination are unambiguous. for (int destination = 0; destination < 16; destination++) { int opcode = (int)current[destination][1].value; if (opcode == 0 || sources[destination] >= 0) continue; int source = -1, candidates = 0, destinations = 0; for (int slot = 0; slot < 16; slot++) { if (!used[slot] && oldOpcodes[slot] == opcode) { source = slot; candidates++; } if (sources[slot] < 0 && current[slot][1].value == opcode) destinations++; } if (candidates == 1 && destinations == 1) { sources[destination] = source; used[source] = true; } } for (int destination = 0; destination < 16; destination++) { int source = sources[destination]; if (source >= 0 && source != destination) { affected[destination] = true; affected[source] = true; } // Includes a same-opcode replacement whose old occupant moved elsewhere. if (oldOpcodes[destination] != 0 && source != destination) affected[destination] = true; } return sources; } static void StepInto(AnimationCurve curve, float previousTime, float transitionTime, bool discrete) { for (int i = 0; i < curve.length; i++) { if (Mathf.Abs(curve[i].time - previousTime) < .00001f) { // Only change the outgoing side; the preceding animation keeps its tangent. AnimationUtility.SetKeyBroken(curve, i, true); AnimationUtility.SetKeyRightTangentMode(curve, i, AnimationUtility.TangentMode.Constant); } if (Mathf.Abs(curve[i].time - transitionTime) < .00001f) { AnimationUtility.SetKeyBroken(curve, i, true); AnimationUtility.SetKeyLeftTangentMode(curve, i, AnimationUtility.TangentMode.Constant); AnimationUtility.SetKeyRightTangentMode(curve, i, discrete ? AnimationUtility.TangentMode.Constant : AnimationUtility.TangentMode.Linear); } } } static void InterpolateBetween(AnimationCurve curve, float a, float b) { float start = Mathf.Min(a, b), end = Mathf.Max(a, b); if (end - start < .00001f) return; for (int i = 0; i < curve.length; i++) { if (Mathf.Abs(curve[i].time - start) < .00001f) { AnimationUtility.SetKeyBroken(curve, i, true); AnimationUtility.SetKeyRightTangentMode(curve, i, AnimationUtility.TangentMode.Linear); } if (Mathf.Abs(curve[i].time - end) < .00001f) { AnimationUtility.SetKeyBroken(curve, i, true); AnimationUtility.SetKeyLeftTangentMode(curve, i, AnimationUtility.TangentMode.Linear); } } } static bool HasNonDefaultInputs(FoldOperator op, List fields, List defaults) { // Identity scale and zero translation are neutral regardless of t. Ignore // unused vector components/fields for these identity configurations. if (op.opcode == 11 && ((Vector3)op.vector0).Equals(Vector3.one)) return false; if (op.opcode == 12 && ((Vector3)op.vector0).Equals(Vector3.zero)) return false; for (int f = 0; f < fields.Count; f++) if (fields[f].name != "opcode" && fields[f].name != "enabled" && !fields[f].value.Equals(defaults[f].value)) return true; return false; } // Recover an existing generated boundary from the saved curves, so this also // works after reopening the editor and after Undo/Redo. Require complete keys // at both ends; partial keys must not be mistaken for a generated boundary. static bool ExistingJumpback(AnimationClip clip, string path, int slot, float time, out float anchor, out bool handoff) { anchor = float.NaN; handoff = false; var fields = Fields(new FoldOperator()); var end = new float[fields.Count]; var start = new float[fields.Count]; int beforeOpcode = 0; for (int f = 0; f < fields.Count; f++) { var curve = AnimationUtility.GetEditorCurve(clip, Binding(path, Prefix(0, slot), fields[f])); if (curve == null) return false; int index = -1; for (int k = 0; k < curve.length; k++) if (Mathf.Abs(curve[k].time - time) < .00001f) { index = k; break; } if (index < 1) return false; float candidate = curve[index - 1].time; if (f == 0) anchor = candidate; else if (Mathf.Abs(candidate - anchor) > .00001f) return false; end[f] = curve[index].value; start[f] = curve[index - 1].value; if (f == 1 && index > 1) beforeOpcode = new DiscreteValue { floating = curve[index - 2].value }.integer; if (!fields[f].discrete && index > 1 && AnimationUtility.GetKeyLeftTangentMode(curve, index - 1) == AnimationUtility.TangentMode.Constant && AnimationUtility.GetKeyRightTangentMode(curve, index - 2) == AnimationUtility.TangentMode.Constant) handoff = true; } if (handoff) return true; int opcode = new DiscreteValue { floating = end[1] }.integer; if (opcode == 0 || beforeOpcode != 0 || !start[1].Equals(end[1])) return false; var saved = new FoldOperator { enabled = end[0] > .5f, opcode = opcode, float0 = end[2], float1 = end[3], float2 = end[4], float3 = end[5], vector0 = new Vector4(end[6], end[7], end[8], end[9]), vector1 = new Vector4(end[10], end[11], end[12], end[13]), vector2 = new Vector4(end[14], end[15], end[16], end[17]), vector3 = new Vector4(end[18], end[19], end[20], end[21]) }; // Ordinary additions have a neutral key, without the stepped handoff. return SameState(start, Fields(saved.JumpbackDefaults())); } public static int Record(AnimationClip clip, Transform root, FoldStack stack, float time, int advanceFrames = 0, System.Func disableJumpBackKeyframing = null) { time = Mathf.Round(Mathf.Max(0, time) * clip.frameRate) / clip.frameRate; float appearanceTime = PreviousAppearanceTime(clip, time); float requestedTransitionTime = time; float previousTime = appearanceTime; if (!float.IsNegativeInfinity(previousTime)) { long frame = Mathf.RoundToInt(time * clip.frameRate); long previousFrame = Mathf.RoundToInt(previousTime * clip.frameRate); // Advance can be negative for backwards authoring. Playback transitions still // belong after the preceding pose, never before it or beyond the current pose. long requested = advanceFrames == 0 ? previousFrame + 1 : frame - System.Math.Abs((long)advanceFrames) + 1; requestedTransitionTime = System.Math.Min(frame, System.Math.Max(previousFrame + 1, requested)) / clip.frameRate; } var bindings = new List(); var curves = new List(); int count = 0; for (int m = 0; m < stack.materials.Length; m++) { var material = stack.materials[m]; if (!material || !material.material) continue; string path = AnimationUtility.CalculateTransformPath(material.transform, root); previousTime = appearanceTime; float transitionTime = requestedTransitionTime; var reuseDefault = new bool[16]; var anchors = new float[16]; for (int slot = 0; slot < 16; slot++) { if (disableJumpBackKeyframing?.Invoke(material, slot) ?? false) continue; if (!ExistingJumpback(clip, path, slot, time, out anchors[slot], out bool handoff)) continue; reuseDefault[slot] = !handoff; if (handoff && Mathf.Abs(anchors[slot] - appearanceTime) < .00001f) { transitionTime = anchors[slot]; previousTime = PreviousAppearanceTime(clip, anchors[slot]); } } var previous = PreviousStates(clip, path, previousTime); var sources = ReorderSources(material, previous, out var affected); for (int s = 0; s < material.slots.Length; s++) { var op = material.slots[s]; float slotTransitionTime = reuseDefault[s] ? anchors[s] : transitionTime; bool cursorOnly = disableJumpBackKeyframing?.Invoke(material, s) ?? false; var fields = Fields(op); int previousOpcode = new DiscreteValue { floating = previous[s][1] }.integer; bool added = op.opcode != 0 && (reuseDefault[s] || (sources[s] < 0 && (previousOpcode == 0 || affected[s]))); var defaults = added ? Fields(op.JumpbackDefaults()) : null; bool needsDefaultKey = added && HasNonDefaultInputs(op, fields, defaults); float defaultTime = Mathf.Max(0, time - advanceFrames / clip.frameRate); // A reordered insertion cannot occupy its destination before the old // operator has moved out. Its default pose belongs at the handoff instead. if (added && affected[s]) defaultTime = slotTransitionTime; if (reuseDefault[s]) defaultTime = anchors[s]; string prefix = Prefix(m, s); var opcodeCurve = added ? AnimationUtility.GetEditorCurve(clip, Binding(path, prefix, fields[1])) : null; if (needsDefaultKey && !reuseDefault[s] && !affected[s] && defaultTime < time && previousOpcode == 0 && opcodeCurve != null) { // Do not replace a different operator's earlier use of this material slot. bool occupied = new DiscreteValue { floating = LastValue(opcodeCurve, defaultTime) }.integer != 0; foreach (var key in opcodeCurve.keys) if (key.time > defaultTime && key.time < time && new DiscreteValue { floating = key.value }.integer != 0) occupied = true; if (occupied && !float.IsNegativeInfinity(previousTime)) defaultTime = Mathf.Max(defaultTime, previousTime); } if ((cursorOnly || !affected[s]) && !Changed(clip, path, prefix, fields, time)) continue; count++; for (int f = 0; f < fields.Count; f++) { var field = fields[f]; var binding = Binding(path, prefix, field); var curve = AnimationUtility.GetEditorCurve(clip, binding) ?? new AnimationCurve(); if (cursorOnly) { Key(curve, time, CurveValue(field), field.discrete); bindings.Add(binding); curves.Add(curve); continue; } if (added && (needsDefaultKey || affected[s] || reuseDefault[s])) { // Reorders still need their structural handoff even for a default // occupant. Ordinary additions at their opcode defaults only get the current key. // Initial empty-stack snapshots inside the new span would interrupt // the default-to-user interpolation. Remove only keys from times when // this slot had no operator, and only inside this operator's new span. float start = Mathf.Min(defaultTime, time), end = Mathf.Max(defaultTime, time); for (int i = curve.length - 1; i >= 0; i--) if (curve[i].time > start + .00001f && curve[i].time < end - .00001f && new DiscreteValue { floating = LastValue(opcodeCurve, curve[i].time) }.integer == 0) curve.RemoveKey(i); if (!float.IsNegativeInfinity(previousTime) && (affected[s] || Mathf.Abs(defaultTime - time) < .00001f)) HoldPreviousState(curve, previousTime, field.discrete); if (Mathf.Abs(defaultTime - time) > .00001f) Key(curve, defaultTime, CurveValue(defaults[f]), field.discrete); } else if (!added) { if (!float.IsNegativeInfinity(previousTime)) HoldPreviousState(curve, previousTime, field.discrete); if (affected[s] && slotTransitionTime < time - .00001f) { float previousValue = sources[s] >= 0 ? previous[sources[s]][f] : 0; if (field.name == "opcode") previousValue = CurveValue(field); Key(curve, slotTransitionTime, previousValue, field.discrete); } } Key(curve, time, CurveValue(field), field.discrete); if (affected[s]) StepInto(curve, previousTime, slotTransitionTime, field.discrete); if (!field.discrete) { // Restore BOTH ends of the interpolated span. Key() preserves // Constant tangents, which may be stale after replacing/clearing // reorder keys. Preserve only the step into the reordered layout. if (affected[s]) InterpolateBetween(curve, slotTransitionTime, time); else if (needsDefaultKey || reuseDefault[s]) InterpolateBetween(curve, defaultTime, time); else if (!added && previousOpcode == op.opcode && !float.IsNegativeInfinity(previousTime)) InterpolateBetween(curve, previousTime, time); } bindings.Add(binding); curves.Add(curve); } } } if (count > 0) { Undo.RegisterCompleteObjectUndo(clip, "Keyframe Fold appearance"); AnimationUtility.SetEditorCurves(clip, bindings.ToArray(), curves.ToArray()); EditorUtility.SetDirty(clip); } return count; } } }