Every Widget Knows How to Arrive and Leave

Menu transitions are the part of a game's interface that separates finished from unfinished, and they're almost always written as one-off code. A screen gets an Update that checks a timer, moves some buttons, fades a logo, and starts the next screen when a counter runs out. Every screen has its own version, they all drift, and none of them can be reused.

My engine takes a different approach: arriving and leaving are properties of a component, set once when it's created.

This continues the series taking one system at a time out of my custom engines.

Three Channels, Two Directions

Every GUI component carries a full animation description, and the API is deliberately repetitive:

SetAnimateMotionInDelay / InTime / InPos / InFunction
SetAnimateMotionOutDelay / OutTime / OutPos / OutFunction

SetAnimateScaleInDelay / InTime / InSize / InFunction
SetAnimateScaleOutDelay / OutTime / OutSize / OutFunction

SetAnimateOpacityInDelay / InTime / InAlpha / InFunction / InInvert / InOverwrite
SetAnimateOpacityOutDelay / OutTime / OutAlpha / OutFunction / OutInvert / OutOverwrite

Three channels that can animate independently, each in two directions, each with four parameters: when to start, how long to take, where to end up, and which curve to follow.

Motion, scale and opacity being separate is what makes the system expressive rather than just convenient. A button can slide up while fading in over a different duration with a different curve, because those are three descriptions rather than one animation. Systems that animate a single opaque "transition" can't express that without adding a special case for every combination anyone wants.

The delay is the parameter that does more than it looks. Give six buttons the same animation with delays of 0.0, 0.05, 0.1, and so on, and they cascade. Staggering is the difference between a menu that appears and a menu that arrives, and here it costs one number per element rather than a sequencing system.

Naming the Combinations

The API is verbose, so the game side wraps common combinations in named macros:

#define SPLASH_LOGO_GROW(obj, delay, runTime)                \
    obj->SetAnimateScaleInDelay(delay);                      \
    obj->SetAnimateScaleInTime(runTime);                     \
    obj->SetAnimateScaleInSize(Vector3(0.0f, 0.0f));         \
    obj->SetAnimateScaleInFunction(InstantOvershootEaseOut);

SPLASH_LOGO_GROW, BOTTOM_BUTTON_ANIM, LOGO_FADE_OUT, SCREEN_FADE_IN. A screen's setup becomes a list of named intentions with timing arguments, rather than twenty setter calls.

That's the right instinct, expressed in the only mechanism a 2014 codebase reached for. The intent is a library of named motion presets, which is exactly what a design system provides and what CSS animation names are for. The implementation is a preprocessor macro with line-continuation backslashes, so it has no type checking, no scoping, and produces unreadable errors when an argument is wrong.

A function taking the component and the timings would have done the same job with all of those problems removed, and a small struct of animation parameters would have done it better still, because then a preset could be stored, passed, and varied rather than only pasted at a call site. Nothing about the idea required the preprocessor. It's a case of the concept being right and the nearest tool being reached for.

The Curves Have Names

The easing functions are the part I'd take anywhere:

Lerp, Slerp, Accelerate, Decelerate,
EaseInQuintic, EaseInCircular, EaseInOvershootOut,
OvershootOut, OvershootEaseOut, AttackEaseOut, InstantOvershootEaseOut

Most of them are cubic Bezier curves solved for a given x, which is the same technique CSS uses for its cubic-bezier() timing functions. Some are closed-form, and the simple ones are constexpr:

inline constexpr float slerpEaseInQuintic(float min, float max, float value)
{
    return (max - min) * value * value * value * value * value + min;
}

The names carry design intent rather than mathematics. InstantOvershootEaseOut describes something that jumps, overshoots, and settles, which is what a logo should do when it lands. AttackEaseOut borrows the word from audio envelopes, where attack is the initial rise. Somebody choosing a curve is picking a feeling, and a name like EaseInOvershootOut communicates the feeling in a way a set of four control points never will.

Every curve also has a slerp variant, so the same easing applies to rotations along the shortest arc rather than through linear component interpolation. Getting that wrong produces rotations that take the long way round or wobble in the middle, and having the pair means the caller picks the interpolation once and both spaces behave.

One List Generates the Machinery

The curves are declared once, in an X-macro:

#define INTERPOLATION_FUNCTIONS()                       \
    XMACRO(Lerp, lerp)                                  \
    XMACRO(Slerp, slerp)                                \
    XMACRO(EaseInQuintic, slerpEaseInQuintic)           \
    ...

and that one list produces the enum, its count, and the dispatch table:

enum InterpolationFunctions
{
#define XMACRO(name, f) name,
    INTERPOLATION_FUNCTIONS()
#undef XMACRO
    InterpolationFunctionsCount,
};

extern float (*InterpolationFunctionPointerArray[InterpolationFunctionsCount])(float, float, float);

inline float interpolate(InterpolationFunctions interpFunc, float x, float a, float b)
{
    return InterpolationFunctionPointerArray[interpFunc](x, a, b);
}

Adding a curve is one line. The enum, the count, and the table can't disagree, because they're all expansions of the same list. The Count sentinel sizing the array means the array is always exactly as long as the enum, forever, without anyone maintaining a number.

This is the same instinct as the scripting layer in the later engine, where a concept derives what's bindable from membership in a variant so there's no second table to keep in sync. Same goal, a decade apart, one solved with the preprocessor and one with the type system. The preprocessor version is harder to debug and it works, and having found the technique early is more interesting than having found the elegant expression of it late.

The Fade Is a Component

One macro gives away how the screen transitions work:

#define ADD_FADE()                                    \
    if (!m_pFade) { m_pFade = new cmGUIImage(); AddComponent(m_pFade); }  \
    m_pFade->SetSize(Vector3(1.0f, 1.0f));            \
    m_pFade->SetTexture(L"TEX_WHITE");                \
    m_pFade->SetAnchorParent(AnchorCenter);           \
    m_pFade->SetAnchorSelf(AnchorCenter);             \
    m_pFade->SetTextureColor(cmColor(0.0f, 0.0f, 0.0f, 0.0f));

A screen fade is a full-screen black image with an opacity animation, added lazily on first use. There's no separate transition system, no render-target trickery, and no special case in the renderer.

That's the reuse the component-level animation buys. Once opacity is animatable on any component, a screen fade is a component, and the same code that fades a button fades the whole screen. A dedicated transition system would have been more code and less capable, because it would only fade screens.

The anchors set to center on both parent and self, with a size of one by one in normalized space, mean the fade covers the screen at any resolution without knowing the resolution.

What I'd Change

Make the presets data rather than macros. A struct of animation parameters, constructed once and applied to components, gives the same named-intent readability with type checking, and allows a preset to be stored in a table, tweaked at runtime, or loaded from a file.

Add a completion signal. The system animates in and out beautifully and has no clean way to say "when the out animation finishes, change screen." That's the one thing every screen still writes by hand, and it's the piece that would have removed the last of the per-screen transition code.

And separate the curve from the channel defaults. Every component sets its own function on every channel, so changing the feel of an entire interface means editing every call site. A default curve per channel, overridable per component, would make "make the whole menu snappier" a one-line change rather than a search and replace.