Skip to content

RUNE-302: Animation Proposed

Updated2026-08-23

Summary

Rune animation is analytical: every animated value is a closed-form function f(t) -> value, not mutable per-frame stepping state. The native engine owns fixed-size descriptors for spring, cubic-bezier, and physics curves, then returns animated Values that the render thread resolves against a shared monotonic clock.

Motivation

Animation must be thread-safe, seekable, cheap to publish with snapshots, and usable for offline/demo rendering. Analytical descriptors avoid tick lists and UI-thread work during steady-state animation while preserving smooth retargeting.

Design

Descriptor Model

Internally, each animation uses a fixed-size RuneAnimDesc:

cpp
enum RuneAnimCurveType : uint8_t {
    RUNE_ANIM_SPRING = 0,
    RUNE_ANIM_BEZIER = 1,
    RUNE_ANIM_PHYSICS = 2,
};

struct RuneAnimDesc {
    float start_value;
    float target_value;
    double start_time;
    double settle_time;
    uint8_t curve_type;
    uint8_t _pad[3];
    float params[6];
};

target_value is the final endpoint for one-shot curves. For physics it is the resolved endpoint used for settled values and conservative bounds; it is not a user-specified target.

Spring

Spring uses a damped harmonic oscillator with Curve parameters:

text
zeta = damping / (2 * sqrt(stiffness))
omega = sqrt(stiffness)
A     = start - target

Under-damped:

text
omega_d = omega * sqrt(1 - zeta^2)
B       = (start_velocity + zeta * omega * A) / omega_d
x(t)    = target + e^(-zeta * omega * t) * (A * cos(omega_d * t) + B * sin(omega_d * t))

Spring settle duration is estimated from the exponential envelope and refined by search. Spring does not repeat.

Cubic Bezier

Bezier uses the CSS timing-function model:

text
x(s) = 3(1-s)^2*s*p1x + 3(1-s)*s^2*p2x + s^3
y(s) = 3(1-s)^2*s*p1y + 3(1-s)*s^2*p2y + s^3

Frame time gives normalized time t = elapsed / duration. The evaluator solves x(s) = t with 4 Newton iterations, then uses y(s) as progress. Control points are CSS-like but all components, including y, are clamped to [0,1], so bezier bounds stay between start and target and do not overshoot.

Settle duration:

  • RUNE_REPEAT_NONE: duration
  • RUNE_REPEAT_LOOP / RUNE_REPEAT_PING_PONG: INFINITY

Physics

Physics solves:

text
dv/dt = gravity - friction * v

For friction > 0:

text
vt = gravity / friction
x(t) = start + vt*t + (v0 - vt) * (1 - exp(-friction*t)) / friction

For friction == 0:

text
x(t) = start + v0*t + 0.5*gravity*t^2

min_value and max_value are terminal bounds, not collision surfaces. Hitting a bound clamps the value and settles the animation; there is no bounce.

Physics settle time:

  • Bound hits are the primary settle condition.
  • Frictionless bound hits use exact linear/quadratic roots.
  • friction > 0 && gravity != 0 bound hits use monotonic-segment bracketing plus 12 bisection iterations.
  • Unbounded or long-running physics keeps settle_time = INFINITY. Gravity-free friction flings store the finite asymptotic limit in target_value for conservative bounds. Other unbounded physics reports an unbounded value range, so layer compositing must not size bitmap-pool entries from the 10-second search cap.

Repeat Modes

c
typedef enum RuneRepeatMode : uint8_t {
    RUNE_REPEAT_LOOP,
    RUNE_REPEAT_PING_PONG,
    RUNE_REPEAT_NONE = 0xFF,
} RuneRepeatMode;

RUNE_REPEAT_NONE is 0xFF so existing callers that pass 0 keep RUNE_REPEAT_LOOP semantics.

Repeat modes apply only to cubic-bezier curves. Loop restarts from the beginning each cycle. Ping-pong alternates forward and reverse playback each cycle.

Timing Model

  • start_time is captured from rune_clock_now() when the descriptor is created.
  • The render thread evaluates every animation in a frame against the same frame_time.
  • Both sides use the same monotonic clock source, so there is no drift between managed and native timing.

Retargeting

Spring retargeting evaluates the old descriptor at now, inherits position and velocity, and creates a replacement spring descriptor.

Bounds analysis marks spring descriptors explicitly unbounded. Underdamped oscillation and inherited/initial velocity can exceed start and target, and exact spring extrema are not currently published. The unbounded marker propagates through scalar expressions so direct and expression-wrapped spring values cannot be clipped by composited layer bounds.

Bezier and physics also start from the current evaluated value when the input Value is animated. Physics uses the inherited velocity when retargeting from an existing animation; the initial_velocity argument is used only when current is static.

Derived RUNE_ANIM_EXPRESSION outputs from CreateExpression() and SamplePath() are downstream-only in V1. Passing them as current to spring/bezier/physics is rejected deterministically instead of retargeting from their stored target_value: spring/bezier return a static target fallback, and physics returns the existing static current-or-zero fallback for invalid animated inputs.

Expression descriptors also publish conservative output ranges for bounds analysis. Scalar creation interprets every current VM opcode over input descriptor intervals. Safe monotonic and arithmetic cases propagate finite endpoints; domain-crossing, non-monotonic, overflow-prone, or otherwise unsupported cases publish the deliberate [-infinity,+infinity] marker. Bounds analysis consumes that marker as unbounded and never substitutes expression start/target values. This does not change target evaluation, settle timing, or settled replay.

Source-Aware Path Sampling

SamplePath is a record operation in the ordered expression VM. Its anonymous DATA_PATH v2 contains original static single-figure bytecode, 12-byte source-segment descriptors, 12-byte cumulative metric knots, total length, and complete conservative source bounds. Knots store (cumulativeLength, segmentIndex, segmentT) rather than flattened X/Y.

At evaluation, progress clamps to [0,1], distance lookup selects the last knot at or before the requested distance, and interpolation stays within one source segment. The evaluator then executes the original Line/Quad/Cubic/Close instruction. Exact segment-boundary ties choose the next positive-length segment at t=0; all-zero paths return their initial point. X/Y descriptor ranges come from full source bounds, so later alignment expressions and snapshot bounds cover the complete trajectory without storing coordinates in every knot.

Lifetime and Memory Model

  • Descriptors live first on the persistent RuneNode.
  • Commit copies live descriptors into snapshot-local anims[].
  • end_update() scans recorded command values and reclaims unreferenced animation slots.
  • The render thread reads only snapshot-local animation copies.
  • Infinite repeat and long-running physics keep snapshots dirty until replaced or removed.

API Surface

Native API

c
Value rune_node_animate(RuneNode* node, Value current, float target, Curve curve);
Value rune_node_animate_bezier(RuneNode* node, Value current, float target,
    float p1x, float p1y, float p2x, float p2y, float duration, uint8_t repeat_mode);
Value rune_node_animate_physics(RuneNode* node, Value current,
    float initial_velocity, float friction, float gravity, float min_value, float max_value);
Value rune_node_animate_repeat(RuneNode* node, float start, float target, float duration, uint8_t mode);
AnimationMetadata rune_node_get_animation_metadata(RuneNode* node, Value value);
RuneStatus rune_node_try_create_expression(
    RuneNode* node, const uint8_t* bytecode, uint32_t bytecode_size,
    Value* out_value);
RuneStatus rune_node_sample_path(
    RuneNode* node,
    const uint8_t* path_bytecode, uint32_t path_bytecode_size,
    const RuneMeasuredPathSegment* segments, uint32_t segment_count,
    const RunePathMetricKnot* knots, uint32_t knot_count,
    float total_length,
    float bounds_min_x, float bounds_min_y,
    float bounds_max_x, float bounds_max_y,
    Value progress, Value* out_x, Value* out_y);

rune_node_animate_repeat() is a compatibility wrapper for linear bezier repeat: bezier(0,0,1,1).

rune_node_get_animation_metadata() returns timing and target metadata for a static or animated Value owned by the node. Static values and invalid animation references return finite zero-duration metadata. One-shot animations return finite settle/duration seconds; repeating animations return infinite settle/duration.

rune_node_try_create_expression() distinguishes malformed scalar bytecode from expression-record or animation capacity failure. The managed wrapper uses this status path and throws rather than returning a static-zero expression fallback.

Managed Wrapper

csharp
Value Animate(Value current, float target, Curve curve);
Value AnimateBezier(Value current, float target, float p1x, float p1y,
                        float p2x, float p2y, float duration,
                        RuneRepeatMode repeatMode = RuneRepeatMode.None);
Value AnimatePhysics(Value current, float initialVelocity, float friction,
                         float gravity, float minValue = float.NegativeInfinity,
                         float maxValue = float.PositiveInfinity);
Value AnimateRepeat(float start, float target, float duration, RuneRepeatMode mode);
AnimationMetadata GetAnimationMetadata(Value value);
(RuneValue X, RuneValue Y) SamplePath(PathBuilder path, RuneValue progress);

Easing provides managed presets: Linear, EaseIn, EaseOut, and EaseInOut. AnimationMetadata is the managed completion view for a Value, allowing higher-level runtimes to wait for finite animations without knowing the concrete curve type.

Examples

csharp
Value scale = node.Animate(currentScale, 0.92f, Curve.Default);
Value x = Easing.EaseInOut(node, currentX, 100.0f, 0.25f);
Value y = node.AnimatePhysics(currentY, initialVelocity: -200.0f, friction: 2.0f,
    gravity: 900.0f, minValue: -100.0f, maxValue: 720.0f);
Value pulse = node.AnimateRepeat(0.0f, 1.0f, 0.8f, RuneRepeatMode.Loop);

Performance

  • Analytical evaluation is thread-safe and seekable.
  • Bezier evaluation uses 4 Newton iterations.
  • Spring and physics use exp plus arithmetic.
  • Steady-state animation requires no UI-thread work after commit.
  • Repeat modes and long-running physics keep snapshots dirty until replaced or removed.

Alternatives Considered

ApproachRejected because
Mutable per-frame stepping stateNot seekable and requires scheduler state
Repeating spring/physicsAmbiguous because endpoints depend on convergence or terminal bounds
Bounce as part of physics boundsRequires restitution/max-bounce semantics and would complicate V1 terminal bounds
Managed retargetingManaged code lacks direct access to old native descriptors and shared clock

Dependencies

  • RUNE-300 provides the ABI contract and shared rune_clock_now() surface.
  • RUNE-301 provides animated Value encoding and resolution.
  • Snapshot publication and traversal consume animation descriptors after commit.

Test Strategy

  1. Validate spring formulas and settle duration.
  2. Verify cubic-bezier one-shot, loop, and ping-pong behavior.
  3. Verify 4-iteration Newton results against high-precision references for common and extreme curves.
  4. Verify physics friction, gravity, combined motion, terminal bounds, and long-running cases.
  5. Verify retargeting from static and already-animated inputs.
  6. Verify managed wrappers return animated Values that resolve correctly on the render thread.
  7. Verify SamplePath source-aware knot lookup, exact boundary ties, Close, all-zero paths, curve evaluation, and source-bounds propagation.
  8. Verify malformed/dynamic/multiple-figure measured payloads and aggregate expression capacity failures are explicit and atomic.

Rune Project brings Rune Story authoring together with the Rune Engine rendering foundation.