|
It
always helps to know what you're getting into. About three years
ago, I was asked to volunteer my time at a junior high school (7th
and 8th graders) to speak with groups of kids. They were having
Career Day, and most of the people coming in to speak about their
careers were parents of the children in school. I had attended this
particular junior high school years ago, and a friend of mine who
now works there contacted me a few days before Career Day to see
if I'd come talk about working in the video game industry.
When I arrived at the school, I checked in at the principal's office,
and amidst some confusion, they were able to tell me where I'd be
presenting. I thought it was strange that they had chosen the girls'
gym as my presentation room, but it didn't concern me too much.
I hadn't been in a junior high school in quite some time, and from
the moment I walked in the front doors, I realized I was in a different,
mostly shorter, world.
I made my way to the girls' gym. The gym teacher was there to greet
me, and she explained how the day's schedule worked. Then she dropped
the bomb. Nobody had told me this yet, but the reason I was asked
to come in was because another presenter had canceled. This didn't
strike me as horrific until I learned two more facts. The other
presenter had canceled after the kids had chosen the careers they
were interested in. The other presenter was scheduled to talk about
sewing.
That day, I learned that 13-year-old girls who are interested in
sewing and junior-high kids who are interested in video games are
mutually exclusive groups. The talk was a disaster. I bombed. The
discussion quickly degenerated to questions like, "Are you a surfer?"
The only thing that saved me was the girls' gym teacher, who kept
the group of giggling girls from exploding into full-on 13-year-old
giggling girl anarchy. By the end of the day, the results were in.
Everybody loved the
firefighter (he brought in lots of cool equipment), and everybody
liked the clown (everybody except, perhaps, the clown's kid). Nobody
mentioned video games. So much for my first attempt at being a positive
role model.
The moral of this story is: Know what you're getting into beforehand.
Applied to 3D animation engines, this lesson dictates that you determine
exactly what you want your engine to do before you begin developing
it. If you don't have a good grasp of the requirements, your initial
engine will have only the most basic features and won't be sufficient
to support your game. You could find yourself adding various capabilities
to the engine during the course of developing your game. Why not
get it right from the start? This article describes a fairly full-featured
interface for just such an engine.
There
are a number of informational resources and sources of inspiration
to investigate before you begin developing your 3D animation engine:
- If
you've already written your own 2D or 3D animation API, consider
what features you'll be able to add to it.
- Get
documentation on commercial high-level 3D APIs that support animation.
They'll inspire you to think in new directions and develop new
features.
- Look
at some other 3D game titles in development. What features do
they use that you would like to implement in your next title?
- Discuss
your ideas with someone who is knee deep in a large project. Their
experience and perspective will undoubtedly expose ideas that
are very useful and not obvious at the start of a project.
The 3D system that you start with will likely fall somewhere close
to OpenGL on the spectrum of high- to low-level APIs. The high-level
end of this spectrum includes all retained-mode systems, while the
lowest level of this spectrum is you, the hardware, and an assembler.
Whichever end of the spectrum you're on, however, you'll want to
write your own animation control system.
At the high level of the API spectrum, there's usually support for
animation, but it's unlikely that it will do you any good for the
following reasons: performance, licensing fees, and feature set.
Retained-mode APIs have a pretty consistent reputation for being
slow and bulky. Some retained-mode APIs require licensing fees that
you may not want to pay. And finally, animation support is often
very basic.
For example, Direct3D Retained Mode allows you to define keyframes
and the current display time for your animated object. You'll have
to add a lot more code to make this system useful to you, and you
have no control over important things such as the internal representation
(storage size and accuracy) of the keyframes. Direct3D Retained
Mode stores rotational keyframes as quaternions defined as four
floats (16 bytes
per keyframe). Using a custom 16-bit value in place of the 32-bit
floats may provide
you all the accuracy you want, and it will cut your animation data
size in half. However, retained mode APIs won't provide you with
this option.
At the low-level end of the spectrum, animation support is nonexistent
and completely up to you. In the midrange, where OpenGL falls, animation
support is also nonexistent. OpenGL, Direct3D Immediate Mode, 3Dfx's
Glide, and consoles provide a rendering pipeline only. Hierarchical
animation takes place in the steps before your data is taken over
by the rendering pipeline.
So let's get down in it. Here, I will propose an interface to a
3D animation system. The only assumption that I'll make about the
underlying engine is that you will implement the capability of interpolating
between arbitrary keyframes.
The
first part of our animation interface will deal with playing animations.
With one very simple function, many different modes of animation
playback are covered.
For all code listings, I'll use a C++ style that assumes we have
a class that implements this animation interface. Every function
interface listed in this article is actually a member function (or
method) of our AnimatedObject
class
(Listing 1). If you're using C, just mentally insert a pointer
to the animated object struct as the first argument of each function,
then grumble about the lameness of C++ for a bit. Likewise, you
can use your imagination to eliminate the default arguments used
in the examples if you are using a strictly C compiler that doesn't
support default arguments.
The PlayAnimation
function,
void PlayAnimation(int animNum,
float startTime=0f,
float transitionTime=0f);
gives us the very basic ability to start playing any animation that
our character is capable of playing, as defined by animNum.
It also allows us to supply a starting point, startTime,
within that animation; depending on the game, there may be many
occasions when you want to start playing an animation "in progress"
by skipping a few milliseconds of that animation. Finally, we allow
ourselves the ability to define the transition time between animations
with the transitionTime
argument. This time can mean different things to different people.
For some, it can define the number of milliseconds inserted between
the previous animation and the one that you are attempting to play.
For others, it can define a period of time during which both animations
are played and averaged together. Either way you look at it, this
time will normally be a standard value that makes the transition
between animations look good. The other common value for transitionTime
will be zero, indicating that the next animation must start immediately
without any interpolation at all.
From PlayAnimation,
we can easily come up with a few commonly used variations to define
within our animated object class.
The function in
Listing 2 will start a particular animation immediately. There
will be no transition between the currently playing animation and
the animation specified in this function call. The starting point
of the new animation will be startTime
milliseconds into the new animation.
The TransitionIntoAnimation
function in
Listing 3 will be the most frequently used function to start
a new animation. All you need to specify is the new animation to
play. The transition period between the currently playing animation
and this new animation is set to the StandardTransitionTime
(a const defined in a header file, typically equivalent to two or
three frames' worth of animation). This function provides the smooth
transitions that you'll normally want to see between animations.
The TransitionIntoAnimationAtTime
function in
Listing 4 will transition (interpolate) into a new animation
and will skip the first few milliseconds of that new animation as
specified by startTime.
All three of these functions are just convenient ways to call PlayAnimation;
they are all ideal candidates for inlining. Each function states
exactly what its purpose is, so you can tell what's going on in
the code without trying to interpret individual arguments to PlayAnimation.
What happens when an animation is finished playing? Our animation
engine has two options. It can loop the current animation, or it
can transition into the next animation. We need a way to define
what the next animation is. This can be achieved with the following
function:
void SetNextAnimation(int
next);
The SetNextAnimation
function can be called at any time, but it is most appropriate to
call it immediately after playing an animation. That way, you know
exactly what will happen after an animation is finished even if
you don't get around to playing another animation. Our engine can
make some fairly intelligent decisions regarding looping animations
at this point. As soon as an animation is played with any variant
of PlayAnimation,
assume that the animation will be looped. Then, as soon as a next
animation is defined via SetNextAnimation,
disable the looped status of the current animation (otherwise the
next animation would never be reached), and assume that the next
animation will be looped. If these assumptions ever change, we'll
conveniently have functions to manipulate or examine the looped
status of the currently playing animation.
The functions in
Listing 5 do exactly what you would expect. If the currently
playing animation is looped (recall that our engine will automatically
loop any played animation), you can change that status with a call
to DisableLoopedAnimation.
If you then change your mind, you can re-enable looping with EnableLoopedAnimation.
IsAnimationLooped
returns the current status.
What would happen if you played an animation, set the next animation,
and then called EnableLoopedAnimation,
like this?
//
Interesting code snippet
PlayAnimation(animNum, startTime);
SetNextAnimation(next);
EnableLoopedAnimation();
In this case, the initial animation will be looped. When you call
SetNextAnimation,
the playing animation will cease to loop. When you re-enable looping
on the current animation via EnableLoopedAnimation,
the next animation that you set will never be played. This is a
perfectly reasonable sequence of actions that could arise in your
game, and no harm will come of it. What happens if your next animation
is playing looped, and you disable its looping? When that animation
ends, your engine won't know what to play. This case can either
be considered a bug (an error message appears with something clever
such as "Should never get here!"), or you can define a default animation
for each animated object that is played and looped when no more
animations are instructed to play.
You can exert even more control over what happens with the next
animation to be played. There will be times when you want the currently
playing animation to end early, but you don't yet have another animation
to play. Usually, the animation that was set with SetNextAnimation
is a safe follow-on to the current animation (otherwise you wouldn't
have set it as the next animation). You can skip directly to the
next animation by calling either of these functions:
void PlayNextAnimation();
void TransitionIntoNextAnimation();
PlayNextAnimation
will start the next animation immediately with no transition. TransitionIntoNextAnimation
will start the next animation with the default amount of interpolated
transition frames. You could add a transitionTime
parameter to the TransitionIntoNextAnimation
function, which would allow you to specify the transition time each
time this function is used. However, in general you want to use
your default transition time, so don't bother with this parameter
unless it's a must.
The final functions related to playing animations allow you to temporarily
disable the entire animation system. They are
void StopAnimation();
void StartAnimation();
If you want the animated object to freeze, or if you want to take
algorithmic control over the animation, then you need to temporarily
disconnect animation playback. StopAnimation
stops animation playback in its tracks. Any updates to the current
animation will be ignored. Even calls to PlayAnimation
should be ignored until the animation is re-enabled with a call
to StartAnimation.
The
animations that you play back with this interface will have keyframes,
and those keyframes will have timestamps that define the animation's
actual playback speed. Those timestamps will most likely be in terms
of frames, where a frame is about 1/30 second. What if you want
to play an animation back at a different speed? Give yourself an
easy way to do this with functions like these:
void SetMsPerFrame(float
newRate=DefaultMsPerFrame);
float GetMsPerFrame();
The preceding functions allow you to set "milliseconds per frame"
to define the playback rate of an animation. If your animations
were originally 30 frames per second, the DefaultMsPerFrame
value about 33.33. By calling SetMsPerFrame
with a value of 66.66, you cut the animation playback rate in half
and everything will play back slower than normal. Smaller values
will make animations play faster. Note that the playback rate set
in the preceding functions should be set on a per-object basis,
and it should stay set for that object until it is changed again
by you. Setting a global playback rate for all objects won't have
the flexibility that you will need.
If you're accustomed to basing your entire life around a vertical
blank interrupt, the idea of setting 33.33 milliseconds per frame
is going to hurt your brain in some way. Sorry about that. There
is some overhead to using milliseconds instead of an integral frame
count, but it's minimal on today's machines. Basing everything on
milliseconds rather than frames makes more sense if you ever plan
on having the game run on more than one machine configuration. For
consoles, there's a discrepancy between PAL (50 FPS) and NTSC (60
FPS). On a PC, the frame rate you can to achieve will fluctuate
according to the performance of the machine on which your game is
running. Basing everything in your game on milliseconds instead
of frames is hard to get used to for some people (including me),
but it's worth the effort.
An
advanced feature that you may want to add to your animation engine
is the ability to capture a channel. If you have a hierarchical
animation system, you will have multiple channels of data for each
animation, such as a root translation channel and a joint rotation
channel for each joint in the hierarchy. There may come a time in
your game when you want to take algorithmic control over one part
of the character's animation and allow the rest of the character
to animate normally. For example, you might want your character
to look in a certain direction while running. To do this, you have
to capture the channel for the neck rotation.
Capturing the channel itself is easy if you have a method of identifying
translation and rotation channels by number. To accomplish this,
you should add the following functions to your engine:
void CaptureRotationChannel(int
which);
void CaptureTranslationChannel(int which);
The numbers that you use to identify channels should correspond
to the numbering or ordering convention of your 3D animation tools.
Your engine will respond to this capture request by simply stopping
the animation for that channel only. For example, you may have 16
joints in a model being animated with rotation information every
frame. Capturing joint 10 means that joint 10 will no longer be
updated with rotation information every frame, but the other 15
joints will continue to be updated.
Once a channel is captured, you can do with it what you like, safe
in the knowledge that changes you make to a captured joint's rotation
or translation won't be clobbered by animation data. How you actually
manipulate that joint depends on how your animation system is implemented.
As
your animation engine grows in complexity, it becomes increasingly
important to know about the state of the engine at various times
during game execution. The solution, of course, is to create functions
that retrieve this state information. Let's look at some of these.
Information regarding the current animation being played is certainly
important. To identify the currently playing animation by number,
use this function:
int GetCurrentAnimation();
To return the current position in the animation being played relative
to the start of that animation (this value will continually reset
to zero for looped animations), use:
float GetAnimationTimeInMs();
Similarly, you can return the current position in the animation
being played relative to the start of that animation in terms of
frames with
float GetAnimationTimeInFrames();
Why create two functions that return the same information in different
formats? Recall that the playback speed of an animation can be changed.
In some cases, you might be interested in knowing whether a particular
frame of an animation has passed yet. Using GetAnimationTimeInFrames,
you can determine this information regardless of the animation's
current playback speed setting.
The InTransition
function should return True
if the animation engine is currently interpolating between two different
animations:
bool InTransition();
The functions in
Listing 6 provide you with additional state information that
you will likely need. The first function, GetNextAnimation,
will tell you what animation is set to be played next. Sometimes,
you need to know very specific information about the current state
of the animation, such as the global position of a particular joint.
Using your joint numbering convention, you can ask for the position
of any joint in terms of world coordinates. In this example, the
GetGlobalPosition
function returns the x, y, and z values individually, but you'll
probably use your own vector type here. The global position of any
joint is essential for collision detection.
The velocity of an animation is an implementation-dependent concept.
If you've extracted a velocity from your animations and stored this
velocity with the animation, you can use GetCurrentVelocity
to find out what the current velocity is. This information is useful
when trying to predict when an animated character will reach a certain
location, for example. The corresponding SetCurrentVelocity
can come in handy if you've determined that an animated character
is moving too slowly to get where it needs to be. Get the velocity,
scale it up, and set it again. SetCurrentVelocity
should override the current animation's velocity only. When a new
animation starts, the correct velocity for that animation should
be used. Note the SetMsPerFrame
function that I already discussed will affect the velocity as well
as playback rate, so if a persistent change in speed is what you're
after, use SetMsPerFrame.
Often, you'll need to know how long an animation will take to play
back. GetAnimLengthInMs
will return that information for any animation that a character
is capable of playing. This information can be useful in hundreds
of situations. This call should take into account the current milliseconds
per frame setting.
In addition to the length of a particular animation, there is a
long list of other information that you may want to know before
playing an animation. For example, you might want to know which
frame of an animation has the largest z translation from the starting
position. For custom information such as this, write a routine that
can query individual frames of individual animations. Note that
in a keyframed, interpolation-based system, the frame that you inquire
about might not physically exist. In this case, the query will have
to create the interpolated frame in order to return the information
that you're after.
Perhaps
you have an animation scripting system that plays back sequences
of animations defined in a text file. Or you may need to queue up
more than one animation at a time. These needs are simple extensions
to the system presented in this article. If you start out with a
fairly complete foundation to your animation system, new and advanced
features will come easily.
Many ideas are presented in this discussion of a theoretical 3D
animation system interface. All of the ideas are from real-world
examples. Nonetheless, your real world will always be different.
The entire set of features that you need can only be determined
by you; hopefully, by now you are thinking well beyond the ideas
presented here and picturing your ideal animation system. And if
anyone asks you to speak to kids about any of this, walk away. Just
walk away.
Scott Corley is a developer at High
Voltage Software. The author would like to acknowledge fellow
High Voltage guy Dwight Luestcher for his part in developing the
concepts discussed in this article
|