Keyframes in Source Code

A real-time strategy base is made of machinery. Chimneys pump, pistons cycle, radar dishes rotate, robotic arms swing. None of it is character animation, none of it responds to anything, and all of it loops forever.

The RTS project on my second engine animates all of that without a single animation file. Every keyframe is a function call in C++.

This is a deep dive on the system that does it, and on what code-driven animation is good at before it stops being good at anything.

A Building Is a Tree of Parts

Each structure is a hierarchy of animated objects, assembled in its initializer:

base.init();
base.mesh = "structure-factory-base.mbm";
base.animationTimer.init(5);

chimney1.init();
chimney1.parent = &base;
chimney1.mesh = "structure-factory-chimney.mbm";
chimney1.animationTimer.init(5);

A part carries a mesh, a texture, a transform, a parent pointer, an animation timer, and two lists of keyframes, one for position and one for rotation. A factory is a base, two chimneys, three pistons, and a door. A research lab is a base, a platform, a pivot, a satellite dish, and four arm segments.

That's the whole model. There's no skeleton, no skinning, no bind pose, and no rig. Rigid parts in a parent hierarchy, which is exactly what industrial machinery is.

Normalized Time Is the Good Idea

Keyframes are authored against a phase from zero to one, not against seconds:

piston1.animationTimer.init(8);
piston1.addKeyframePosition(0,    Vector3(15.169, -8.168, 5.032));
piston1.addKeyframePosition(0.5f, Vector3(15.169, -8.168, 8.051));
piston1.addKeyframePosition(1.0f, Vector3(15.169, -8.168, 5.032));

The timer supplies the period. init(8) means the cycle takes eight seconds, and the update converts elapsed time into phase:

float time = animationTimer.eventTimer / animationTimer.eventTime;

Separating phase from period is the decision that makes the system pleasant to work with. Making a piston pump faster is changing 8 to 4. Every keyframe stays where it is, because the keyframes describe the motion and the timer describes how long it takes.

Had keyframes been authored in seconds, changing the speed would mean rescaling every time value in the animation, and doing that by hand is where transcription errors come from.

Cyclic by Construction

The keyframe lookup has one detail that decides the whole character of the system. It searches for the last keyframe at or before the current phase, then the first keyframe after it. When there is no keyframe after, it wraps:

if (!found) {
    endTime = 1;
    endData = keyframesRotation.begin()->data;
}

Past the final keyframe, the animation interpolates back toward the first one. Every animation loops, and there's no way to author one that doesn't.

For this game that's the correct constraint rather than a limitation. Nothing in an RTS base plays once. The chimney has been pumping since the building finished and will still be pumping when the match ends. Building a system that can only express loops, for a game made entirely of loops, means no state to track about whether an animation is playing, no completion callbacks, and no way to leave a building frozen mid-piston because something forgot to restart it.

It also means the system cannot do a door opening, and there's a door part in the factory that has no keyframes at all.

Phase Offset as an Authoring Technique

The two chimneys share a period and differ only in their values:

chimney1.addKeyframePosition(0,    Vector3(-4.296, 10.848, 13.914));
chimney1.addKeyframePosition(0.5f, Vector3(-4.296, 10.848, 17.787));

chimney2.addKeyframePosition(0.0f, Vector3(-11.468, 10.848, 17.787));
chimney2.addKeyframePosition(0.5f, Vector3(-11.468, 10.848, 13.914));

Chimney one is at the bottom of its stroke when chimney two is at the top. The three pistons do the same: one and three extend at the half-phase, two retracts.

There's no phase-offset parameter anywhere in the system. The alternation is produced by writing the values in the opposite order, which works and is the kind of thing that's obvious while writing it and opaque three years later. An offset field on the timer would have expressed the intent directly, made it adjustable without rewriting values, and let identical parts share one set of keyframes.

There are also plateau keyframes, two entries with the same value at 0.0 and 0.1, which hold a part still for a tenth of the cycle before it moves. That's an ease-in built by hand out of linear segments.

Two Curves

Interpolation offers a choice:

enum AnimationCurve { Linear, Smooth };

Smooth is smoothstep, which eases in and out. Linear is linear. That's the entire curve library, and for machinery it covers nearly everything: mechanical motion is either constant-velocity or eased at the ends of a stroke.

What's missing is per-keyframe curves. The mode is a property of the whole object, so a part can't ease out of one keyframe and snap into the next. A piston that should punch out fast and return slowly can't be expressed, and the workaround is more keyframes with hand-computed values.

Where It Falls Down

The costs compound.

The lookup is two linear scans of a linked list, per property, per part, per frame. Keyframes live in a std::list, which has no random access and poor locality, and the update walks it from the beginning twice: once to find the preceding keyframe and once to find the following one. For six parts with five keyframes each this is invisible. It's still the wrong structure for a sorted sequence searched by a monotonically advancing key, where the right answer is a vector and a remembered index that almost always advances by zero or one.

Every instance owns its keyframes. The parts are by-value members of the building class, so ten factories carry ten identical copies of the same animation data. The same engine gets this right in its particle system, where one interpolation table is shared by every particle, and in the script system of the later engine, where one parsed program serves every entity. Animation data is the same kind of thing and was never given the same treatment.

Tuning requires a recompile. The keyframe values are C++ literals, so adjusting a chimney's stroke means editing source, rebuilding, and relaunching. The evidence of what that costs is still in the file, in commented-out blocks of earlier keyframe values sitting directly above the current ones. That's the iteration loop preserved in amber: change numbers, rebuild, look, keep the old version around in case the new one is worse.

The Comparison That Makes the Point

The same engine ships a particle editor, with a live preview and a lookup table that rebuilds the moment a value changes. Particle effects were authored by dragging sliders and watching. Building animations were authored by editing constants and recompiling.

Both are the same kind of content: a small set of numbers describing motion over time, tuned by eye, needing many iterations to look right. One got a tool and the other didn't.

The reason isn't a principle, it's that particles hurt more. There are more of them, they're harder to visualize from numbers, and they were used in more places. The animation system had six parts on a handful of buildings, and editing constants was tolerable enough that it never crossed the threshold where somebody builds a tool.

That's the actual mechanism behind which parts of a codebase get invested in, and it matters because "tolerable" is a moving line. Six buildings is tolerable. Sixty is not, and the tool gets built at a point where it has to be retrofitted onto content that already exists.

What Transfers

Separate phase from period. Author motion in normalized time and let a single number set the duration, so speed is adjustable without touching the motion.

Constrain the system to what the domain needs. Loop-only was the right call for machinery, and it removed an entire category of state.

Make the offset a parameter rather than a convention. Alternation expressed by reordering values works and communicates nothing.

Share the data. Animation curves are immutable and identical across instances, which is the definition of something that should live once.

And watch for the content type that's tolerable to edit by hand. It's the one that will still be edited by hand when there's ten times as much of it.