Skip to content

Animation

Skewer supports keyframe animation for any transformable element in the scene graph. This guide covers the animation system, motion blur, and how to create animated renders.

Overview

Animation in Skewer is defined through keyframed transforms on scene graph nodes. Each keyframe specifies a time and a transform (translation, rotation, scale), and Skewer interpolates between them during rendering.

{
  "name": "earth_orbit",
  "transform": {
    "keyframes": [
      { "time": 0, "rotate": [0, 0, 0] },
      { "time": 1, "rotate": [0, 360, 0] }
    ]
  },
  "children": [
    {
      "type": "obj",
      "file": "Earth.obj",
      "transform": { "translate": [42.5, 0, 0], "scale": 4 }
    }
  ]
}

In this example, a group node rotates 360 degrees around the Y-axis from time 0 to time 1, carrying its child (the Earth model) in an orbital path.

The scene camera can also be keyframed with camera.keyframes. Camera keyframes use the same time axis and patch-style carry-forward behavior, but animate camera fields instead of TRS:

"camera": {
  "look_from": [0, 2, 5],
  "look_at": [0, 0, 0],
  "vup": [0, 1, 0],
  "vfov": 50,
  "aperture_radius": 0.02,
  "focus_distance": 5,
  "keyframes": [
    { "time": 0, "look_from": [0, 2, 5] },
    { "time": 2, "look_from": [3, 2, 5], "focus_distance": 3, "aperture_radius": 0.08 }
  ]
}

Moving cameras are evaluated at each sampled ray time, so camera motion contributes true motion blur. Camera animation does not expand BVH/TLAS bounds because the scene geometry has not moved, but every render layer becomes frame-varying when the camera has more than one keyframe.

Keyframe Structure

Each keyframe in the keyframes array has:

Field Type Required Description
time float Yes Time value in arbitrary animation units
translate Vec3 No Position [x, y, z]. Omitted fields accumulate from previous keyframe
rotate Vec3 No Rotation in degrees [rx, ry, rz] (Euler angles). Accumulates from previous
scale float or Vec3 No Uniform scale (number) or per-axis scale [sx, sy, sz]. Accumulates from previous
curve string or object No (default "linear") Interpolation curve to the next keyframe

TRS Accumulation

Keyframes use accumulative interpolation: when a field is omitted from a keyframe, it retains the value from the previous keyframe (or the default for the first keyframe — translate [0,0,0], rotate [0,0,0], scale [1,1,1]).

"keyframes": [
  { "time": 0, "translate": [0, 0, 0] },
  { "time": 1, "translate": [5, 0, 0] },
  { "time": 2 }
]
  • At time 0: translate = [0, 0, 0]
  • At time 1: translate = [5, 0, 0]
  • At time 2: translate = [5, 0, 0] (accumulated from time 1, since omitted)

This means the object moves from origin to [5, 0, 0] in the first second, then stays there.

Rotation Accumulation

Rotation values accumulate, which enables continuous spinning:

"keyframes": [
  { "time": 0, "rotate": [0, 0, 0] },
  { "time": 0.5, "rotate": [0, 180, 0] },
  { "time": 1, "rotate": [0, 360, 0] },
  { "time": 1.5, "rotate": [0, 540, 0] },
  { "time": 2, "rotate": [0, 720, 0] }
]

The object completes two full rotations over 2 time units. The rotation is interpolated via spherical linear interpolation (SLERP) on quaternions, which avoids gimbal lock and produces smooth rotation.

Clamping

When the animation time falls outside the keyframe range, the transform is clamped to the nearest endpoint:

  • t <= first_keyframe.time → returns first keyframe's transform
  • t >= last_keyframe.time → returns last keyframe's transform

There is no automatic looping or ping-ponging — you must design keyframes to cover the full animation duration.

Interpolation Curves

The curve field on each keyframe controls how the interpolation eases between that keyframe and the next.

Preset Curves

Preset Description Visual
"linear" Constant speed (default) Straight line
"ease-in" Slow start, fast end Accelerating
"ease-out" Fast start, slow end Decelerating
"ease-in-out" Slow start and end, fast middle S-curve

Custom Bezier Curves

For fine-grained control, specify a cubic Bezier curve:

"curve": { "bezier": [0.25, 0.1, 0.25, 1.0] }

The four values are [p1x, p1y, p2x, p2y] — the control points of a cubic Bezier where P0=(0,0) and P3=(1,1) are fixed. This is the same format as CSS cubic-bezier().

The curve maps a normalized time parameter u in [0, 1] to an eased value in [0, 1]. Skewer uses Newton's method to solve for the Bezier parameter t given u, then evaluates the Y component.

Curve Assignment

The curve on a keyframe governs the interpolation from that keyframe to the next. The curve on the last keyframe is ignored (there is no next keyframe to interpolate toward).

Scene Graph Transforms

Static Transforms

A transform without keyframes is a single static TRS:

"transform": {
  "translate": [1, 2, 3],
  "rotate": [0, 45, 0],
  "scale": 2.0
}

Transform Inheritance

Transforms compose down the scene graph. A child node's world transform is the composition of all ancestor transforms with its own:

world_transform = parent_transform × grandparent_transform × ... × own_transform

The composition follows standard TRS math: - Scale: parent_scale × child_scale (component-wise) - Rotation: parent_rotation × child_rotation (quaternion multiplication) - Translation: parent_translation + parent_rotation × (parent_scale × child_translation)

Animated Groups

When a group node has an animated transform, all children inherit the animated transform at each ray time:

world_position(t) = group_transform(t) × child_transform × local_position

This is the standard pattern for orbital animation: animate the group's rotation, and children orbit around the group's origin.

Motion Blur

Motion blur is produced by sampling rays at random times within the camera's shutter interval:

"camera": {
  "shutter_open": 0.0,
  "shutter_close": 0.1
}

How It Works

  1. Each ray sample gets a random time: ray_time = shutter_open + random() × (shutter_close - shutter_open)
  2. Animated transforms are evaluated at ray_time via AnimatedTransform::Evaluate(ray_time)
  3. Bounding volumes are expanded to cover the full shutter interval for acceleration structure correctness
  4. The accumulation of samples at different times produces motion blur

Matching Shutter to Animation

The shutter interval determines how much motion is captured:

If animation runs from time 0 to time 1:
  shutter [0, 0.1] → captures 10% of total motion (light blur)
  shutter [0, 0.5] → captures 50% of total motion (medium blur)
  shutter [0, 1.0] → captures full motion (heavy blur)

Shutter Interval

For a specific motion blur amount, set shutter_close - shutter_open to the fraction of the animation you want blurred. A 10% blur on a 1-second animation uses [0, 0.1].

Performance Impact

Motion blur significantly increases noise because each sample effectively renders a different frame of the animation. Expect to need 2-4× more samples than a static scene for comparable quality.

Bounding Volume Expansion

Animated objects require expanded bounding volumes for the BVH acceleration structure. The bounding box is computed as the union of the object's bounds at shutter_open and shutter_close. This can make the BVH less efficient for fast-moving objects, slightly increasing ray intersection cost.

Acceleration Structure for Animation

Skewer uses a two-level BVH:

  • Bottom-level BVH: Static mesh geometry, built once per layer load
  • Top-level BVH (TLAS): Instance bounds over the shutter window; animated instance transforms are evaluated at ray time during traversal

For animated instances, the TLAS evaluates transforms at the ray's time. For static instances, the transform is precomputed at t=0. Animated cameras only change primary rays and deep-output camera depth projection; they do not require different BLAS/TLAS bounds.

Static vs Animated Instances

An instance is classified as animated if any node in its transform chain has more than one keyframe. Static instances use a single precomputed transform, which is faster for ray traversal. If only part of a scene is animated, ensure the animated nodes are in separate groups to minimize the number of animated instances.

Common Patterns

Orbital Motion

{
  "name": "orbit_group",
  "transform": {
    "keyframes": [
      { "time": 0, "rotate": [0, 0, 0] },
      { "time": 1, "rotate": [0, 360, 0] }
    ]
  },
  "children": [
    {
      "type": "obj",
      "file": "planet.obj",
      "transform": { "translate": [10, 0, 0] }
    }
  ]
}

Linear Translation

{
  "name": "moving_camera_rig",
  "transform": {
    "keyframes": [
      { "time": 0, "translate": [-5, 2, 0] },
      { "time": 1, "translate": [5, 2, 0], "curve": "ease-in-out" }
    ]
  },
  "children": [ ... ]
}

Pulsing Scale

{
  "transform": {
    "keyframes": [
      { "time": 0, "scale": 1.0 },
      { "time": 0.5, "scale": 1.2, "curve": "ease-out" },
      { "time": 1, "scale": 1.0, "curve": "ease-in" }
    ]
  }
}

See Also