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