From 744cc632ab4dcc5e840feb047429d24db2fbde61 Mon Sep 17 00:00:00 2001 From: Yoshi Askharoun Date: Sat, 25 Jul 2026 21:37:32 -0500 Subject: [PATCH] Implement keyframed animations --- UIX.RenderApi.OpenGL/Animation/AnimValue.cs | 107 +++++++++ .../Animation/AnimationEasing.cs | 34 +++ .../Animation/AnimationTargetApplier.cs | 132 +++++++++++ UIX.RenderApi.OpenGL/Animation/GLAnimation.cs | 45 +++- .../Animation/GLAnimationSystem.cs | 9 +- .../Animation/GLKeyframeAnimation.cs | 210 +++++++++++++++++- UIX.RenderApi.OpenGL/OpenGLRenderApi.cs | 6 +- .../Microsoft/Iris/Render/AnimationInput.cs | 10 + .../Iris/Render/ConstantAnimationInput.cs | 6 + logs/UIX.RenderApi.OpenGL/Implementation.md | 49 ++++ 10 files changed, 587 insertions(+), 21 deletions(-) create mode 100644 UIX.RenderApi.OpenGL/Animation/AnimValue.cs create mode 100644 UIX.RenderApi.OpenGL/Animation/AnimationEasing.cs create mode 100644 UIX.RenderApi.OpenGL/Animation/AnimationTargetApplier.cs diff --git a/UIX.RenderApi.OpenGL/Animation/AnimValue.cs b/UIX.RenderApi.OpenGL/Animation/AnimValue.cs new file mode 100644 index 0000000..47c2c3c --- /dev/null +++ b/UIX.RenderApi.OpenGL/Animation/AnimValue.cs @@ -0,0 +1,107 @@ +using System; + +namespace Microsoft.Iris.Render.OpenGL +{ + /// + /// A resolved animation value as up to four float channels plus its logical type. + /// Reads keyframe inputs through the public + /// accessor and folds expressions over their public operands. + /// Object/continuous inputs (whose value depends on a live render object) are not + /// resolvable here and report failure so the caller can hold the previous value. + /// + internal readonly struct AnimValue + { + public readonly AnimationInputType Type; + public readonly float X, Y, Z, W; + + public AnimValue(AnimationInputType type, float x, float y, float z, float w) + { + Type = type; + X = x; + Y = y; + Z = z; + W = w; + } + + public static bool TryRead(AnimationInput input, out AnimValue value) + { + value = default; + if (input == null) + return false; + + if (input is BinaryOperation op) + { + if (!TryRead(op.LeftOperand, out AnimValue l) || !TryRead(op.RightOperand, out AnimValue r)) + return false; + value = op.Operation == BinaryOpCode.Multiply + ? new AnimValue(l.Type, l.X * r.X, l.Y * r.Y, l.Z * r.Z, l.W * r.W) + : new AnimValue(l.Type, l.X + r.X, l.Y + r.Y, l.Z + r.Z, l.W + r.W); + return true; + } + + if (!input.TryGetConstantValue(out object raw) || raw == null) + return false; + + switch (raw) + { + case float f: value = new AnimValue(input.InputType, f, 0f, 0f, 0f); return true; + case Vector2 v2: value = new AnimValue(input.InputType, v2.X, v2.Y, 0f, 0f); return true; + case Vector3 v3: value = new AnimValue(input.InputType, v3.X, v3.Y, v3.Z, 0f); return true; + case Vector4 v4: value = new AnimValue(input.InputType, v4.X, v4.Y, v4.Z, v4.W); return true; + case Quaternion q: value = new AnimValue(input.InputType, q.X, q.Y, q.Z, q.W); return true; + default: return false; + } + } + + /// Interpolate between two values by t∈[0,1]; quaternions use slerp. + public static AnimValue Lerp(AnimValue a, AnimValue b, float t, bool spherical) + { + if (a.Type == AnimationInputType.Quaternion && (spherical || b.Type == AnimationInputType.Quaternion)) + return Slerp(a, b, t); + return new AnimValue( + a.Type, + a.X + (b.X - a.X) * t, + a.Y + (b.Y - a.Y) * t, + a.Z + (b.Z - a.Z) * t, + a.W + (b.W - a.W) * t); + } + + private static AnimValue Slerp(AnimValue a, AnimValue b, float t) + { + float dot = a.X * b.X + a.Y * b.Y + a.Z * b.Z + a.W * b.W; + float bx = b.X, by = b.Y, bz = b.Z, bw = b.W; + if (dot < 0f) + { + dot = -dot; + bx = -bx; by = -by; bz = -bz; bw = -bw; + } + + float wa, wb; + if (dot > 0.9995f) + { + wa = 1f - t; + wb = t; + } + else + { + float theta = (float)Math.Acos(dot); + float sin = (float)Math.Sin(theta); + wa = (float)Math.Sin((1f - t) * theta) / sin; + wb = (float)Math.Sin(t * theta) / sin; + } + return new AnimValue( + AnimationInputType.Quaternion, + a.X * wa + bx * wb, + a.Y * wa + by * wb, + a.Z * wa + bz * wb, + a.W * wa + bw * wb); + } + + public float AsFloat() => X; + public Vector2 AsVector2() => new Vector2(X, Y); + public Vector3 AsVector3() => new Vector3(X, Y, Z); + public Vector4 AsVector4() => new Vector4(X, Y, Z, W); + + public float GetChannel(int index) => index switch { 0 => X, 1 => Y, 2 => Z, _ => W }; + } +} diff --git a/UIX.RenderApi.OpenGL/Animation/AnimationEasing.cs b/UIX.RenderApi.OpenGL/Animation/AnimationEasing.cs new file mode 100644 index 0000000..adcd347 --- /dev/null +++ b/UIX.RenderApi.OpenGL/Animation/AnimationEasing.cs @@ -0,0 +1,34 @@ +using System; + +namespace Microsoft.Iris.Render.OpenGL +{ + /// + /// Maps a keyframe's to an eased parameter. + /// The original curves are evaluated in native code, so these are standard easings + /// matching each curve's name/family (see logs/UIX.RenderApi.OpenGL/Implementation.md). + /// + internal static class AnimationEasing + { + public static float Ease(AnimationInterpolation? interpolation, float t) + { + if (t <= 0f) return 0f; + if (t >= 1f) return 1f; + + return interpolation switch + { + LinearInterpolation => t, + EaseInInterpolation => t * t, + EaseOutInterpolation => t * (2f - t), + SCurveInterpolation => t * t * (3f - 2f * t), + SineInterpolation => 0.5f * (1f - (float)Math.Cos(Math.PI * t)), + CosineInterpolation => 1f - (float)Math.Cos(t * (Math.PI / 2.0)), + ExponentialInterpolation => (float)Math.Pow(2.0, 10.0 * (t - 1.0)), + LogarithmicInterpolation => 1f - (float)Math.Pow(2.0, -10.0 * t), + // Bezier control points are internal to the curve; smoothstep is a + // reasonable stand-in until they can be read. + BezierInterpolation => t * t * (3f - 2f * t), + _ => t, + }; + } + } +} diff --git a/UIX.RenderApi.OpenGL/Animation/AnimationTargetApplier.cs b/UIX.RenderApi.OpenGL/Animation/AnimationTargetApplier.cs new file mode 100644 index 0000000..8b8ff9f --- /dev/null +++ b/UIX.RenderApi.OpenGL/Animation/AnimationTargetApplier.cs @@ -0,0 +1,132 @@ +using System; + +namespace Microsoft.Iris.Render.OpenGL +{ + /// + /// Writes a resolved onto a target render object's named + /// animatable property, honoring an optional channel mask. Property names/types match + /// the original render objects (Position/Size/Scale/Alpha/Rotation/… on visuals, + /// CameraEye/At/Up/Zn on cameras, Offset/ColorMask on gradients, dynamic on effects). + /// + internal static class AnimationTargetApplier + { + public static void Apply(IAnimatable target, string property, string? mask, AnimValue value) + { + // Effects use dynamic (custom) properties with no readable current value, so + // they only support a full write (masks are not applied to them). + if (target is GLEffect effect) + { + ApplyToEffect(effect, property, value); + return; + } + + AnimationTypeMask channelMask = AnimationTypeMask.FromString(mask); + float[] channels; + if (channelMask.ChannelCount == 0) + { + channels = new[] { value.X, value.Y, value.Z, value.W }; + } + else + { + if (!TryGetChannels(target, property, out channels)) + return; + for (int i = 0; i < channelMask.ChannelCount; i++) + { + AnimationTypeChannel ch = channelMask[i]; + if (ch != AnimationTypeChannel.O) + channels[(int)ch - 1] = value.GetChannel(i); + } + } + + SetChannels(target, property, channels); + } + + private static void ApplyToEffect(GLEffect effect, string property, AnimValue v) + { + switch (v.Type) + { + case AnimationInputType.Float: effect.SetProperty(property, v.AsFloat()); break; + case AnimationInputType.Vector2: effect.SetProperty(property, v.AsVector2()); break; + case AnimationInputType.Vector3: effect.SetProperty(property, v.AsVector3()); break; + default: effect.SetProperty(property, v.AsVector4()); break; + } + } + + private static bool TryGetChannels(IAnimatable target, string property, out float[] channels) + { + channels = new float[4]; + switch (target) + { + case GLVisual visual: + switch (property) + { + case "Position": Store(channels, visual.Position); return true; + case "Size": channels[0] = visual.Size.X; channels[1] = visual.Size.Y; return true; + case "Scale": Store(channels, visual.Scale); return true; + case "Alpha": channels[0] = visual.Alpha; return true; + case "CenterPoint": Store(channels, visual.CenterPoint); return true; + case "Rotation": + Store(channels, visual.Rotation.Axis); + channels[3] = visual.Rotation.Angle; + return true; + default: return false; + } + case GLCamera camera: + switch (property) + { + case "CameraEye": Store(channels, camera.Eye); return true; + case "CameraAt": Store(channels, camera.At); return true; + case "CameraUp": Store(channels, camera.Up); return true; + case "CameraZn": channels[0] = camera.Zn; return true; + default: return false; + } + default: + return false; + } + } + + private static void SetChannels(IAnimatable target, string property, float[] c) + { + switch (target) + { + case GLVisual visual: + switch (property) + { + case "Position": visual.Position = new Vector3(c[0], c[1], c[2]); break; + case "Size": visual.Size = new Vector2(c[0], c[1]); break; + case "Scale": visual.Scale = new Vector3(c[0], c[1], c[2]); break; + case "Alpha": visual.Alpha = c[0]; break; + case "CenterPoint": visual.CenterPoint = new Vector3(c[0], c[1], c[2]); break; + case "Rotation": visual.Rotation = new AxisAngle(new Vector3(c[0], c[1], c[2]), c[3]); break; + case "Orientation": visual.Rotation = QuaternionToAxisAngle(c[0], c[1], c[2], c[3]); break; + } + break; + case GLCamera camera: + switch (property) + { + case "CameraEye": camera.Eye = new Vector3(c[0], c[1], c[2]); break; + case "CameraAt": camera.At = new Vector3(c[0], c[1], c[2]); break; + case "CameraUp": camera.Up = new Vector3(c[0], c[1], c[2]); break; + case "CameraZn": camera.Zn = c[0]; break; + } + break; + } + } + + private static void Store(float[] channels, Vector3 v) + { + channels[0] = v.X; + channels[1] = v.Y; + channels[2] = v.Z; + } + + private static AxisAngle QuaternionToAxisAngle(float x, float y, float z, float w) + { + w = w < -1f ? -1f : (w > 1f ? 1f : w); + float angle = 2f * (float)Math.Acos(w); + float s = (float)Math.Sqrt(Math.Max(0f, 1f - w * w)); + Vector3 axis = s < 1e-4f ? new Vector3(0f, 0f, 1f) : new Vector3(x / s, y / s, z / s); + return new AxisAngle(axis, angle); + } + } +} diff --git a/UIX.RenderApi.OpenGL/Animation/GLAnimation.cs b/UIX.RenderApi.OpenGL/Animation/GLAnimation.cs index 10e3923..b28341e 100644 --- a/UIX.RenderApi.OpenGL/Animation/GLAnimation.cs +++ b/UIX.RenderApi.OpenGL/Animation/GLAnimation.cs @@ -3,15 +3,14 @@ using System.Collections.Generic; namespace Microsoft.Iris.Render.OpenGL { /// - /// Shared state machine for animations: play/pause/reset, repeat counting and the - /// async-notify event. Target property interpolation is intentionally minimal here - /// (see ); full per-frame evaluation is a stage-3 TODO. + /// Shared state machine for animations: play/pause/reset, repeat and the async-notify + /// event. Time evaluation lives in the concrete subclasses (see ). /// public abstract class GLAnimation : SharedRenderObject, IAnimation { public int RepeatCount { get; set; } - public bool IsPlaying { get; private set; } - public bool IsActive { get; private set; } + public bool IsPlaying { get; protected set; } + public bool IsActive { get; protected set; } public bool AutoReset { get; set; } public AnimationResetBehavior ResetBehavior { get; set; } = AnimationResetBehavior.LeaveCurrent; @@ -23,7 +22,11 @@ namespace Microsoft.Iris.Render.OpenGL IsActive = true; } - public virtual void Pause() => IsPlaying = false; + public virtual void Pause() + { + if (IsActive) + IsPlaying = false; + } public virtual void Reset() { @@ -41,10 +44,15 @@ namespace Microsoft.Iris.Render.OpenGL protected void RaiseAsyncNotify(int cookie) => AsyncNotifyEvent?.Invoke(cookie); - /// Advance internal time. Called by the animation system each pulse. - internal virtual void Advance(int advanceMs) { } + /// Advance internal time by . Driven by the system's pulse. + internal abstract void Advance(int advanceMs); } + /// + /// Aggregates child animations and drives them together. The public API exposes no way + /// to add members (IAnimationGroup has no members beyond IAnimation), so membership is + /// only available internally; kept for lifecycle parity. + /// public sealed class GLAnimationGroup : GLAnimation, IAnimationGroup { private readonly List m_members = new List(); @@ -52,16 +60,31 @@ namespace Microsoft.Iris.Render.OpenGL public override void Play() { base.Play(); - foreach (var a in m_members) + foreach (GLAnimation a in m_members) a.Play(); } + public override void Pause() + { + base.Pause(); + foreach (GLAnimation a in m_members) + a.Pause(); + } + + public override void Reset() + { + base.Reset(); + foreach (GLAnimation a in m_members) + a.Reset(); + } + internal void Add(GLAnimation animation) => m_members.Add(animation); internal override void Advance(int advanceMs) { - foreach (var a in m_members) - a.Advance(advanceMs); + foreach (GLAnimation a in m_members) + if (a.IsPlaying) + a.Advance(advanceMs); } } } diff --git a/UIX.RenderApi.OpenGL/Animation/GLAnimationSystem.cs b/UIX.RenderApi.OpenGL/Animation/GLAnimationSystem.cs index f058cc6..b3c5bf2 100644 --- a/UIX.RenderApi.OpenGL/Animation/GLAnimationSystem.cs +++ b/UIX.RenderApi.OpenGL/Animation/GLAnimationSystem.cs @@ -11,13 +11,20 @@ namespace Microsoft.Iris.Render.OpenGL private readonly List m_animations = new List(); private bool m_paused; + private bool m_backCompat; + public int UpdatesPerSecond { get; set; } = 60; public float SpeedAdjustment { get; set; } = 1f; - public bool BackCompat { set { /* compatibility flag; no behavioral change */ } } + + // When set, keyframe 0 is not auto-populated with the initial value (matching the + // original AnimationSystem.BackCompat behavior). + public bool BackCompat { set => m_backCompat = value; } public IKeyframeAnimation CreateKeyframeAnimation(object objUser, AnimationInput initialValue) { var a = new GLKeyframeAnimation(initialValue); + if (!m_backCompat) + a.AddInitialKeyframe(); m_animations.Add(a); return a; } diff --git a/UIX.RenderApi.OpenGL/Animation/GLKeyframeAnimation.cs b/UIX.RenderApi.OpenGL/Animation/GLKeyframeAnimation.cs index 2bcc876..099a19d 100644 --- a/UIX.RenderApi.OpenGL/Animation/GLKeyframeAnimation.cs +++ b/UIX.RenderApi.OpenGL/Animation/GLKeyframeAnimation.cs @@ -3,12 +3,21 @@ using System.Collections.Generic; namespace Microsoft.Iris.Render.OpenGL { /// - /// Keyframe animation. Stores keyframes, targets and events and runs the play-state - /// machine. Smooth per-frame evaluation and target property mutation are a stage-3 - /// TODO; today it drives lifecycle/events so higher layers sequence correctly. + /// Time-driven keyframe animation. On each pulse it advances its clock, finds the + /// surrounding keyframes, eases and interpolates between their (constant) values and + /// writes the result onto every target property. Supports repeat/auto-reset/reset + /// behavior and additive/multiplicative Reference/Scale inputs. /// + /// + /// Keyframe times are in seconds. Matching the original, keyframe 0 at t=0 holds the + /// initial value (added here unless the system is in BackCompat mode). Stage/time/ + /// progress/value events are recorded but not yet dispatched — see the log for why + /// (their targets require the render-internal IActivatableObject). + /// public sealed class GLKeyframeAnimation : GLAnimation, IKeyframeAnimation { + private static readonly LinearInterpolation s_defaultInterpolation = new LinearInterpolation(); + private readonly struct Target { public readonly IAnimatable Object; @@ -25,23 +34,32 @@ namespace Microsoft.Iris.Render.OpenGL private readonly List m_keyframes = new List(); private readonly List m_targets = new List(); private readonly List m_events = new List(); + private readonly AnimationInput m_initialValue; + + private float m_timeSec; + private int m_loopsCompleted; public GLKeyframeAnimation(AnimationInput initialValue) { - InitialValue = initialValue; + m_initialValue = initialValue; Type = initialValue.InputType; } + /// + /// Seed keyframe 0 (t=0) with the initial value. The animation system calls this + /// unless it is in BackCompat mode, matching the original renderer's constructor. + /// + internal void AddInitialKeyframe() + => m_keyframes.Add(new AnimationKeyframe(0f, m_initialValue, s_defaultInterpolation)); + public int KeyframeCount => m_keyframes.Count; - public AnimationInput InitialValue { get; } + public AnimationInput InitialValue => m_keyframes.Count > 0 ? m_keyframes[0].Value : m_initialValue; public AnimationInput Reference { get; set; } = null!; public AnimationInput Scale { get; set; } = null!; public AnimationInputType Type { get; } public void AddKeyframe(AnimationKeyframe keyframe) => m_keyframes.Add(keyframe); - public AnimationKeyframe GetKeyframe(int keyframeIndex) => m_keyframes[keyframeIndex]; - public void SetKeyframe(int keyframeIndex, AnimationKeyframe keyframe) => m_keyframes[keyframeIndex] = keyframe; public void AddTarget(IAnimatable targetObject, string targetProperty) @@ -62,5 +80,183 @@ namespace Microsoft.Iris.Render.OpenGL public void AddValueEvent(ValueEventCondition condition, AnimationInput reference, AnimationEvent animationEvent) => m_events.Add(animationEvent); public void RemoveEvent(AnimationEvent animationEvent) => m_events.Remove(animationEvent); public void RemoveAllEvents() => m_events.Clear(); + + // ---- Lifecycle ----------------------------------------------------------- + public override void Play() + { + // Replaying after a completed run restarts from the top. + if (IsActive && !IsPlaying && m_timeSec >= Duration) + { + m_timeSec = 0f; + m_loopsCompleted = 0; + } + base.Play(); + } + + public override void Reset() + { + base.Reset(); + m_timeSec = 0f; + m_loopsCompleted = 0; + ApplyResetBehavior(); + } + + public override void InstantAdvance(float advanceTime) + { + if (advanceTime > 0f) + AdvanceBy(advanceTime); + } + + public override void InstantFinish() + { + float duration = Duration; + m_timeSec = duration; + ApplyAt(duration); + IsPlaying = false; + m_loopsCompleted = RepeatCount < 0 ? 0 : RepeatCount; + if (AutoReset) + Reset(); + } + + internal override void Advance(int advanceMs) + { + if (IsPlaying && advanceMs > 0) + AdvanceBy(advanceMs / 1000f); + } + + // ---- Evaluation ---------------------------------------------------------- + /// Total animation length in seconds (largest keyframe time). + private float Duration + { + get + { + float max = 0f; + foreach (AnimationKeyframe k in m_keyframes) + if (k.Time > max) + max = k.Time; + return max; + } + } + + private bool IsInfinite => RepeatCount < 0; + + private void AdvanceBy(float dt) + { + float duration = Duration; + m_timeSec += dt; + + if (duration <= 0f) + { + ApplyAt(0f); + Complete(); + return; + } + + while (m_timeSec >= duration) + { + if (IsInfinite || m_loopsCompleted < RepeatCount) + { + m_timeSec -= duration; + m_loopsCompleted++; + } + else + { + m_timeSec = duration; + ApplyAt(duration); + Complete(); + return; + } + } + + ApplyAt(m_timeSec); + } + + private void Complete() + { + IsPlaying = false; + if (AutoReset) + Reset(); + } + + private void ApplyResetBehavior() + { + switch (ResetBehavior) + { + case AnimationResetBehavior.SetInitialValue: + ApplyAt(0f); + break; + case AnimationResetBehavior.SetFinalValue: + ApplyAt(Duration); + break; + // LeaveCurrent: nothing to do. + } + } + + private void ApplyAt(float time) + { + if (m_keyframes.Count == 0 || m_targets.Count == 0) + return; + if (!SampleValue(time, out AnimValue value)) + return; + + value = ApplyReferenceAndScale(value); + + foreach (Target target in m_targets) + AnimationTargetApplier.Apply(target.Object, target.Property, target.Mask, value); + } + + private bool SampleValue(float time, out AnimValue value) + { + value = default; + + // Keyframes are authored in time order; sort defensively so out-of-order + // additions still evaluate correctly. + m_keyframes.Sort((a, b) => a.Time.CompareTo(b.Time)); + + if (time <= m_keyframes[0].Time) + return AnimValue.TryRead(m_keyframes[0].Value, out value); + + AnimationKeyframe last = m_keyframes[m_keyframes.Count - 1]; + if (time >= last.Time) + return AnimValue.TryRead(last.Value, out value); + + for (int i = 0; i < m_keyframes.Count - 1; i++) + { + AnimationKeyframe a = m_keyframes[i]; + AnimationKeyframe b = m_keyframes[i + 1]; + if (time < a.Time || time > b.Time) + continue; + + float span = b.Time - a.Time; + float localT = span > 0f ? (time - a.Time) / span : 1f; + float eased = AnimationEasing.Ease(b.Interpolation, localT); + + bool haveA = AnimValue.TryRead(a.Value, out AnimValue va); + bool haveB = AnimValue.TryRead(b.Value, out AnimValue vb); + if (haveA && haveB) + { + bool spherical = b.Interpolation?.UseSphericalCombination ?? false; + value = AnimValue.Lerp(va, vb, eased, spherical); + return true; + } + // If only one endpoint is readable (e.g. the other is object-relative), + // hold that endpoint rather than skipping the whole frame. + if (haveA) { value = va; return true; } + if (haveB) { value = vb; return true; } + return false; + } + return false; + } + + private AnimValue ApplyReferenceAndScale(AnimValue value) + { + // Effective = Reference + Scale * value (both optional). The exact native + // combination is unverified; this is the conventional interpretation. + if (Scale != null && AnimValue.TryRead(Scale, out AnimValue s)) + value = new AnimValue(value.Type, value.X * s.X, value.Y * s.Y, value.Z * s.Z, value.W * s.W); + if (Reference != null && AnimValue.TryRead(Reference, out AnimValue r)) + value = new AnimValue(value.Type, value.X + r.X, value.Y + r.Y, value.Z + r.Z, value.W + r.W); + return value; + } } } diff --git a/UIX.RenderApi.OpenGL/OpenGLRenderApi.cs b/UIX.RenderApi.OpenGL/OpenGLRenderApi.cs index 506d3dc..69de3c4 100644 --- a/UIX.RenderApi.OpenGL/OpenGLRenderApi.cs +++ b/UIX.RenderApi.OpenGL/OpenGLRenderApi.cs @@ -12,8 +12,10 @@ namespace Microsoft.Iris.Render.OpenGL /// Create an OpenGL render engine for the given Iris engine info. public static IRenderEngine CreateEngine(IrisEngineInfo engineInfo, IRenderHost renderHost) { - ArgumentNullException.ThrowIfNull(engineInfo); - ArgumentNullException.ThrowIfNull(renderHost); + // Explicit null checks (not ArgumentNullException.ThrowIfNull, which is net6+ + // and does not exist on the net48 target). + if (engineInfo == null) throw new ArgumentNullException(nameof(engineInfo)); + if (renderHost == null) throw new ArgumentNullException(nameof(renderHost)); return new GLRenderEngine(engineInfo, renderHost); } } diff --git a/UIX.RenderApi/Microsoft/Iris/Render/AnimationInput.cs b/UIX.RenderApi/Microsoft/Iris/Render/AnimationInput.cs index fc76690..74c6c68 100644 --- a/UIX.RenderApi/Microsoft/Iris/Render/AnimationInput.cs +++ b/UIX.RenderApi/Microsoft/Iris/Render/AnimationInput.cs @@ -48,6 +48,16 @@ namespace Microsoft.Iris.Render public AnimationInputType InputType => this.m_inputType; + // Public read accessor so an out-of-assembly animation engine can evaluate + // keyframe values without reaching internal state. Only constant inputs carry a + // directly-readable value; expression/object inputs return false (a consumer can + // still walk BinaryOperation's public operands, and read the leaves this way). + public virtual bool TryGetConstantValue(out object value) + { + value = null; + return false; + } + internal AnimationInputType SourceType => this.m_sourceType; internal AnimationTypeMask SourceMask => this.m_sourceMask; diff --git a/UIX.RenderApi/Microsoft/Iris/Render/ConstantAnimationInput.cs b/UIX.RenderApi/Microsoft/Iris/Render/ConstantAnimationInput.cs index 3cd9398..7bba73e 100644 --- a/UIX.RenderApi/Microsoft/Iris/Render/ConstantAnimationInput.cs +++ b/UIX.RenderApi/Microsoft/Iris/Render/ConstantAnimationInput.cs @@ -74,6 +74,12 @@ namespace Microsoft.Iris.Render internal object RawValue => this.m_inputValue; + public override bool TryGetConstantValue(out object value) + { + value = this.m_inputValue; + return true; + } + private object ApplyMask(AnimationTypeMask mask, object value) { object obj = null; diff --git a/logs/UIX.RenderApi.OpenGL/Implementation.md b/logs/UIX.RenderApi.OpenGL/Implementation.md index 5881d46..4069f34 100644 --- a/logs/UIX.RenderApi.OpenGL/Implementation.md +++ b/logs/UIX.RenderApi.OpenGL/Implementation.md @@ -2,6 +2,55 @@ Reverse-chronological log (prepend new entries; never edit older ones). +## 2026-07-25 — Real keyframe animation evaluation + +Replaced the no-op animation stubs with a working evaluator. `GLKeyframeAnimation` +now advances a clock, finds the surrounding keyframes, eases + interpolates their +values and writes the result onto every target property. Repeat, auto-reset and +reset-behavior are implemented; Reference/Scale are applied as `ref + scale*value`. + +New files: `Animation/AnimValue.cs` (resolve/lerp/slerp values), +`Animation/AnimationEasing.cs` (curve → eased t), `Animation/AnimationTargetApplier.cs` +(write value to a named property with channel masking). `GLAnimation` gained +protected play-state setters + an abstract `Advance`. + +**One UIX.RenderApi change (approved by the human — "public accessors"):** added +`public virtual bool AnimationInput.TryGetConstantValue(out object)` (false by default) +and an override on `ConstantAnimationInput` returning its masked value. This is the +only supported way for a separate assembly to read keyframe values — the payload was +`internal`, and the original animation *engine* lives inside UIX.RenderApi so it never +needed a public accessor. `BinaryOperation` already exposes its operands publicly, so +expression inputs (relative keyframes) fold over `TryGetConstantValue` leaves; no +reflection is used anywhere. + +Confirmed against the UIX consumer (`AnimationManager`, `KeyframeAnimation`): +- UIX sets `BackCompat = true`, so keyframe 0 is NOT auto-populated with the initial + value (we honor the flag; the auto-keyframe only happens when BackCompat is false). +- UIX drives `PulseTimeAdvance` itself, so the render loop must NOT pulse animations. +- Keyframe 0 at t=0 = initial value (original behavior, used when !BackCompat). + +Documented assumptions (unverifiable — the real curves/formulas run in native code; +logged per the CLAUDE.md unknowns procedure): +- Time units: pulse is milliseconds (`nAdvanceMs`), keyframe times / InstantAdvance are + seconds. Our conversion matches both. +- RepeatCount: 0 = play once, N>0 = N extra loops (N+1 total), <0 = infinite. +- Easing shapes are standard curves matched to each interpolation class's name + (Bezier falls back to smoothstep since its control points are internal). +- Reference/Scale combine as `reference + scale*value`. + +Known gap (NOT done — needs a decision): stage/time/progress/value **event dispatch**. +`AnimationEvent`'s ctor hard-casts its target to the render-internal +`IActivatableObject`, which an external animation object cannot implement, so UIX's +`AnimationProxy` (which registers `AnimationEvent(anim, "AsyncNotify", …)` for +Complete/Reset) can't target our animations, and completion/reset notifications don't +flow back. Resolving this needs `IActivatableObject` made public + implemented on our +animation objects (+ an in-process activation dispatch), a larger change than the value +accessor. Events are stored today but not fired. Flagged to the human. + +Validation: compiled the whole project against the prebuilt `UIX.RenderApi.dll` + a +one-method harness shim for the new `TryGetConstantValue` (the prebuilt DLL predates it) +— build succeeded. + ## 2026-07-25 — Input-event translation (Silk.NET.Input) Wired real keyboard/mouse input via `GLInputTranslator`, created by the engine on