|
|
|
Contents: |
|
|
|
Related content: |
|
|
|
Subscriptions: |
|
|
| Take the heavy coding out of fixed-object
animation
Barry
A. Feigenbaum, Ph.D. (mailto:feigenba@us.ibm.com?cc=&subject=2D
animation with image-based paths), Senior Consulting IT Architect,
IBM Tom
Brunet (mailto:tomab@cs.wisc.edu?cc=&subject=2D
animation with image-based paths), Ph.D. candidate, University of
Wisconsin
9 January 2004
Why code your animated sequences when you can draw what you
want and let a program do the rest? In this article, Barry Feigenbaum
and Tom Brunet show you how to combine lossless images, Swing
technology, and the authors' own Java-based animation engine to generate
movement sequences for fixed objects in 2D animation.
In two-dimensional (2D) animation it is often necessary to move an
object or objects around the 2D area in predetermined patterns, sometimes
called control paths. This type
of animation requires that you solve two problems:
- How to specify the control paths for the objects to follow.
- How to move the objects along the selected paths.
In this article we'll show you how to solve these problems using
lossless images, Swing technology, and a Java-based animation engine.
We'll start by drawing the desired trajectory for our animated objects,
and then use the animation engine to drive the objects along the defined
control paths.
While lossless images (described below) are both easy to
create and easy to process, the technique of using them can be as finely
tuned as you want to make it. Using an example animation sequence, you'll
learn how different color sets can be used to create more and less
sophisticated movement sequences. You'll also learn how to process your
image to extract out the desired control paths, layer the control paths
against a background image, create the objects (Swing GUI components) for
your animation sequence, and drive them along the defined control paths to
complete the animation process. See Resources for more
information.
Note: This article
assumes you have a working knowledge of Java programming in general and
Swing GUI construction in particular. Some additional experience
manipulating images on the Java platform using Java 2D will be
helpful.
What's not to
lose? A lossless image is one in
which all the image pixels are permanently retained. The image must be of
a type that can be either stored exactly or recovered to an exact replica
of the original.
You can use a variety of applications programs to create lossless
images, including Microsoft Paint, Jasc Paint Shop Pro, and some custom
applications. You can store the images in files or create them only in
RAM. The images must either be stored uncompressed or compressed using a
lossless compression scheme, such as zip compression. Typical lossless
image formats include Microsoft's Bitmap (BMP) and the Portable Network
Graphics (PNG) format. Lossy compression schemes such as those often used
for GIF (Graphics Interchange Format) and JPEG (Joint Photographic Experts
Group) files will not work for the animation techniques described in this
article.
It's all a matter of
control In its most general form a control path represents
the behavior to be taken at a particular position and time through any n-dimensional space. We
define a control path as the path taken by one or more objects through a
2D space. You represent a control path by mapping an object's position to
a behavior at that position. A program then iterates through the defined
objects, looks up the behavior for the position of the object in the map,
and performs the instructed action on the object. For all but the simplest
of control paths, creating such a mapping in code can be both time
consuming and error prone; thus a drawing program is more appropriate.
Control paths can be time invariant, in which
case they are static, or time variable, in which
case they are dynamic. If your lossless image is contained in an image
file, it will be time invariant, or static. If your lossless image is
contained in RAM and used directly, it will be time variable, or dynamic.
In this article we'll focus on static control paths. With the right
editing program, static images can be much easier to generate, although
the types of behavior defined will also affect the process to some
degree.
Let's have a hot time
tonight! A good way to learn about animation is to do it
yourself. We'll use an animation example to illustrate the concepts
discussed throughout the remainder of the article. Our example is an
animated fire escape sequence, so we'll generate control paths to
represent the escape trajectory for several figures. We'll use the partial
floor plan in Figure 1 as a background image. You can see the full-size
background image in Figure 6.
Figure 1. A portion of the background
image
We can generate the control mapping from any array of values. Using an
image for the array (as shown in Figure 2) allows us to use a color value
to represent the behavior at each location. The size (number of color
bits) of each color value will depend on the image format. Figure 2
illustrates some of the control paths for our fire escape sequence.
Figure 2. Some of the control paths
To see how the control path image corresponds to the animation
background, we can overlay the control image on top of the background
image, as shown in Figure 3.
Figure 3. The images combined using a layered
transparency
Color my world After
an image is generated, it can be easily converted to the needed mapping.
We simply iterate through the colors of the image and assign a behavior
for each color value. So, for example we could use white, which is
typically an all-ones value, to indicate no mapping or a default behavior.
Black, which is typically a zero value, could be used to represent a
custom behavior. If mapped according to our image, when the object
encounters a position that shares the same behavior (that is, the same
color, say black) it will continue in the direction defined by that
position. If the position does not share the same behavior, it will find
an adjacent position that shares its behavior without backtracking.
We can represent other behaviors with different colors. Those color
values not defined will be ignored. Thus, pixels in the background (such
as the light-gray pixels in Figure 3) can be
ignored.
Listing 1 shows how the mapping is accomplished. The image is first
scanned for specific color values, then the position of each color pixel
is used to define a control state at that location in the map of control
states. In the Escape example, six behaviors are defined by the various
STATE_xxx constants. Listing 1.
Processing a control path image
Map map = new HashMap();
:
public final static int STATE_UNKNOWN = -1;
public final static int STATE_NONE = 0;
public final static int STATE_HALLWAY = 1;
public final static int STATE_INTERSECTION = 2;
public final static int STATE_HINT = 3;
public final static int STATE_START = 4;
public final static int STATE_EXIT = 5;
:
/** Process the control image */
void processControl(Image img, int x, int y, int w, int h)
{
int pmap[] = new int[w * h];
PixelGrabber pg = new PixelGrabber(img, x, y, w, h, pmap, 0, w);
try {
pg.grabPixels();
if ((pg.getStatus() & ImageObserver.ABORT) != 0) {
System.err.println("image fetch error");
}
else {
Integer none = new Integer(STATE_NONE);
Integer hall = new Integer(STATE_HALLWAY);
Integer start = new Integer(STATE_START);
Integer exit = new Integer(STATE_EXIT);
Integer hint = new Integer(STATE_HINT);
Integer inter = new Integer(STATE_INTERSECTION);
// for each position
for (int i = 0; i < pmap.length; i++) {
int red = (pmap[i] >> 16) & 0xff;
int green = (pmap[i] >> 8) & 0xff;
int blue = (pmap[i] ) & 0xff;
if (red == 255 && green == 255 && blue == 255)
; // don't bother to add NONE to map
else if (red == 0 && green == 0 && blue == 0)
map.put(new Integer(i), hall);
else if (red == 0 && green == 255 && blue == 0)
map.put(new Integer(i), start);
else if (red == 200 && green == 0 && blue == 0)
map.put(new Integer(i), exit);
else if (red == 255 && green == 0 && blue == 0)
map.put(new Integer(i), hint);
else if (red == 0 && green == 0 && blue == 255)
map.put(new Integer(i), inter);
}
}
}
catch (InterruptedException e) {
System.err.println("image processing interrupted");
}
}
|
Variety is the spice of
life Although specific color values are used in Listing 1
(that is, red is 200, green is 0, and blue is 0) we could easily enhance
the code to support color ranges. Using color ranges reduces the precision
of the color selection used in the drawing program, thus making it easier
to create a path image.
Using a wider selection of colors allows you to define many more
states, and also to describe much more complicated behaviors. For example,
you could use different color bands in an RGB scheme to create overlapping
control paths. If each of the above states were encoded by different
intensities of a single color rather than by different colors, three
independent control paths could be overlaid on top of each other. Of
course, using different intensities of a single color makes it harder to
discern the subtle color differences between behaviors. Most image editor
programs display exact color values of the selected pixel, mitigating this
problem.
It's also possible to define more than three control paths. If you
accessed each color value through a bitmask, you would be limited only by
the number of bits in your image format (typically 24; 32 if you used
alpha values). Using single bits for a path is more complex than using
color bands but it can be done. You would either need to have a program
merge together separate image control paths or use a single image and
additive painting. If you did not need to support overlapping paths (that
is, have multiple states exist at one position), you could have 2^24 (or
2^32) states at each position. You could also mix these two approaches.
For example, using the red band via a bitmask and the green and blue bands
for other states.
Figure 4 shows the complete control path image used for our Escape
simulation. Notice the use of multiple colors, and how the colors are used
to represent different behaviors at different locations.
Figure 4. The whole control path
Figure 5 magnifies a particular section of the control path for greater
clarity.
Figure 5. Partial control path
detail
Run for your
lives! After we've defined a state map, we can begin to move
around objects in the 2D space. The example Escape application models
moveable objects as instances of the Entity class. Two major
subclasses have been defined: a Person and an
Alarm . Person s can move while
Alarm s are stationary. The Entity interface is
defined in Listing 2. Listing 2. The Entity
interface
interface Entity {
void addToPanel(JPanel panel, boolean shared);
void updateTick();
}
|
The addToPanel() method creates one or more Swing
components to represent the objects and adds them to the provided panel.
The components are typically JLabel s with an icon set. The
panel is typically the implementation of the 2D space. Its background
displays the animation background.
The updateTick() method causes the object to animate
itself for each cycle of the animation. Alarm objects change
their color to create a flashing effect. Person objects move.
Alarm objects are simple components that blink, as
implemented in Listing 3. Listing 3.
Alarm.updateTick: Flash
public void updateTick() {
if (++tick % CYCLE == 0) {
opaque = !opaque;
}
}
|
Person objects are more complex than Alarm s.
They move about along the defined control paths as shown in Listing
4. Listing 4. Person.updateTick: Move along the
path
/** Move one step along the path */
public synchronized void updateTick() {
tick++;
Integer tock = stops.get(new Integer(tick));
if (tock != null) { // adjust startTime if requested
startTick = tick + tock.intValue();
}
if (tick < startTick) return; // not my time yet
if (isAtExit()) return;
// Process individual movement
Point2D location = getPosition();
int x = (int)location.getX();
int y = (int)location.getY();
switch (manager.stateAt(x, y)) {
case BuildingManager.STATE_EXIT:
atExit = true;
break;
case BuildingManager.STATE_START:
case BuildingManager.STATE_INTERSECTION:
// process any hints
if (manager.stateAt(x - 1, y) ==
BuildingManager.STATE_HINT)
setDirection(Person.DIR_WEST);
else if (manager.stateAt(x + 1, y) ==
BuildingManager.STATE_HINT)
setDirection(Person.DIR_EAST);
else if (manager.stateAt(x, y + 1) ==
BuildingManager.STATE_HINT)
setDirection(Person.DIR_SOUTH);
else if (manager.stateAt(x, y - 1) ==
BuildingManager.STATE_HINT)
setDirection(Person.DIR_NORTH);
// no hints, select a direction
if (getDirection() == DIR_NONE) {
if (manager.stateAt(x - 1, y) !=
BuildingManager.STATE_NONE)
setDirection(Person.DIR_WEST);
else if (manager.stateAt(x + 1, y) !=
BuildingManager.STATE_NONE)
setDirection(Person.DIR_EAST);
else if (manager.stateAt(x, y + 1) !=
BuildingManager.STATE_NONE)
setDirection(Person.DIR_SOUTH);
else if (manager.stateAt(x, y - 1) !=
BuildingManager.STATE_NONE)
setDirection(Person.DIR_NORTH);
}
case BuildingManager.STATE_HALLWAY:
case BuildingManager.STATE_HINT:
// effect motion in selected direction
int tempX = x;
int tempY = y;
switch (getDirection()) {
case DIR_EAST: x += 1; break;
case DIR_WEST: x -= 1; break;
case DIR_NORTH: y -= 1; break;
case DIR_SOUTH: y += 1; break;
}
int check = manager.stateAt(x, y);
if (check == manager.STATE_UNKNOWN ||
check == manager.STATE_NONE) {
// went off the path, backup
x = tempX;
y = tempY;
if (getDirection() == DIR_EAST ||
getDirection() == DIR_WEST) {
if (manager.stateAt(x, y + 1) !=
BuildingManager.STATE_NONE &&
manager.stateAt(x, y + 1) !=
BuildingManager.STATE_UNKNOWN) {
setDirection(Person.DIR_SOUTH);
y += 1;
}
else {
// Only direction not checked is north
setDirection(Person.DIR_NORTH);
y -= 1;
}
}
else {
if (manager.stateAt(x + 1, y) !=
BuildingManager.STATE_NONE &&
manager.stateAt(x + 1, y) !=
BuildingManager.STATE_UNKNOWN) {
setDirection(Person.DIR_EAST);
x += 1;
}
else {
// Only direction not checked is south
setDirection(Person.DIR_WEST);
x -= 1;
}
}
}
setNextPoint(new Point(x, y));
}
}
|
This fairly complex method basically examines the map around the
current position. It then selects the best new position to go to. It works
by attempting to go in the same direction as much as possible. Note that
hints are markers that
provide a preferred direction. They are typically used at starting
locations or intersections.
A Person can stop prematurely (that is, before reaching
the end of a path), or be set to delay the start of motion for a fixed
time. Person objects are also capable of leaving a fading
trail of images (or history) to depict their motion, as shown in Figure
6.
Figure 6. A person moving with history
shadows
Everyone gets a
turn Listing 5 shows the logic for moving entities. This
process is performed once per animation cycle. Listing 5. Move all entities
/** Move the entities around the pattern */
public void moveEntities() {
// update (move) the people
for (Iterator iter = people.iterator(); iter.hasNext();) {
Object next = iter.next();
if (next instanceof Person) {
((Person)next).updateTick();
}
}
// update the other entities
for (Iterator iter = entities.iterator(); iter.hasNext();) {
Object next = iter.next();
if (next instanceof Entity) {
((Entity)next).updateTick();
}
}
}
|
Each new frame in the animation is created by the process shown in
Listing 6. Note that the entities add themselves to the frame. Listing 6. Add entities at current positions
/** Advance the animation */
public void prepareNextFrame(boolean update, boolean shared) {
setBackground(Color.black);
if (update) {
manager.moveEntities();
}
mainPanel.setBounds(getBounds());
mainPanel.removeAll();
// add the people
for (Iterator iter = manager.getPeople().iterator();
iter.hasNext();) {
Person person = (Person)iter.next();
person.addToPanel(mainPanel, shared);
}
// add the entities
for (Iterator iter = manager.getEntities().iterator();
iter.hasNext();) {
Entity entity = (Entity)iter.next();
entity.addToPanel(mainPanel, shared);
}
}
|
We achieve continuous animation with the code in Listing 7. The
delay value controls how fast the animation runs, and also
how much CPU time it takes. Listing 7. For the life
of the animation
public void runUpdates(int delay) {
TimerTask task = new TimerTask() {
public void run() {
SwingUtilities.invokeLater(new Runnable() {
public void run() {
prepareNextFrame();
repaint();
if (manager.getPeople().size() == 0) {
cancel();
}
}
});
}
};
(new Timer(true)).scheduleAtFixedRate(task, 0, delay);
}
|
Below are several snapshots of our animated sequence. Figure 7 shows
the escape simulation just before the action heats up (approximately 10
percent complete).
Figure 7. Escape sequence near the
start
Figure 8 shows the animated sequence at approximately 50 percent
complete.
Figure 8. The escape sequence following some
updates
Figure 9 shows the sequence near its completion (although the sequence
actually runs forever).
Figure 9. Near the end of the escape
sequence
In Figure 10 you can see how the entities move along the control paths,
which should give you a greater idea of how the motion is actually
achieved.
Figure 10. Entities in motion along the control
paths
Painting the
scene Class BuildingViewer creates the
container in which the objects are moved. The paintChldren()
method first draws the background image, then any alert message, and
finally the subcomponents representing the various entities. Listing 8. BuildingViewer.paintChildren
private int paintCount;
private static final Color evacColor = new Color(255, 0, 0, 128);
:
/** draw the background, massage and entities */
public void paintChildren(Graphics g) {
paintCount++;
if (background != null) {
Graphics g2 = g.create();
try {
// draw the background
g2.drawImage(background.getImage(),
(int)getLocation().getX(),
(int)getLocation().getY(),
(int)getLocation().getX() + getWidth(),
(int)getLocation().getY() + getHeight(),
0, 0,
background.getIconWidth(),
background.getIconHeight(),
Color.black, null);
// draw the alert message (if any)
if (alertMessage != null) {
if (paintCount % alertPeriod >= (alertPeriod / 2)) {
Font f = g2.getFont();
Font f2 = f.deriveFont((float)alertSize);
FontMetrics fm = Toolkit.getDefaultToolkit().
getFontMetrics(f2);
int fHeight = fm.getHeight(),
fAscent = fm.getAscent();
int sWidth = fm.stringWidth(alertMessage);
g2.setFont(f2);
Graphics2D g2d = (Graphics2D)g2;
g2d.setStroke(new BasicStroke(10));
g2.setColor(evacColor);
g2.drawString(alertMessage,
(getWidth() - sWidth) / 2,
(getHeight() - fHeight) / 2 +
Ascent);
}
}
} finally {
g2.dispose();
}
}
super.paintChildren(g);
}
|
To create an effective animation, you need objects to animate. Listing
9 shows the code to create a series of Person entities of the
same type (that is, disabled, non-disabled, firefighters, etc.) based on
the provided inputs. Listing 9. Create entities of
the same type
/** initialize paths */
protected static void initLoop(BuildingManager manager, ImageIcon icon,
int[] locs, String[] names,
int[] starts, int[] appear, int[][][] stops)
{
LinkedList startPts = (LinkedList)manager.
getAvailableStartingPoints();
// for all specified locations - create a Person
for (int i = 0; i < locs.length; i++) {
JLabel label = new JLabel(names[i], icon, JLabel.CENTER);
label.setFont(new Font(label.getFont().getName(),
label.getFont().getStyle(), 20));
Person person = new Person(manager, label,
(Point2D)startPts.get(
Math.min(startPts.size() - 1, locs[i])),
starts[i]);
person.setAppearTick(appear[i]);
// defines stop locations for each Person
for (int j = 0; j < stops[i].length; j++) {
person.addStop(stops[i][j][0], stops[i][j][1]);
}
manager.addEntity(person);
}
}
|
Listing 10 defines a set of Person entities using the
initLoop code from Listing 9. This code uses several parallel
arrays (based on the length of the locs array) to provide
information about the objects to be created. The locs array
provides an index into the set of defined starting locations, as provide
by the control path. The starts value specifies at what timer
tick the Person is to begin to move. The appear
value defines the timer tick when the Person should become
visible (often before it starts to move). The stops values
specify the (possibly multiple) stop points each Person can
have.
Although shown as hand-typed values below, it is possible to get most
of these input values from the control path by adding new colors that
represent states that position entities. This enhancement can simplify the
input of these values and make them less subject to error when the control
paths change. Listing 10. Create all Person
entities
/** Make some demo people */
static public void createPeople(BuildingManager manager,
ImageIcon employIcon,
ImageIcon fireIcon,
ImageIcon disabledIcon)
{
// Main character - ALEX
int locs[] = new int[] {42};
String names[] = new String[] {"Alex"};
int starts[] = new int[] { 300 };
int appear[] = new int[] { 0 };
int stops[][][] = new int[][][] {{}};
initLoop(manager, employIcon, locs, names, starts, appear, stops);
// Some disabled people
locs = new int[] { 39, 45 };
names = new String[] { "Karen", "Mike" };
starts = new int[] { 0, 0};
appear = new int[] { 0, 0 };
stops = new int[][][] {{{1, 164}, {560, 20}},
{{1, 141}, {460, 30}}};
initLoop(manager, disabledIcon, locs, names, starts, appear, stops);
// Some Assisters
locs = new int[] {44, 49, 37, 46};
names = new String[] { "Tom", "Joe", "Cathy", "Larry" };
starts = new int[] { 0, 0, 0, 0};
appear = new int[] { 0, 0, 0, 0 };
stops = new int [][][] {{{120, 52}, {560, 20}},
{{155, 24}, {560, 20}},
{{122, 27}, {460, 30}},
{{100, 59}, {460, 30}}};
initLoop(manager, employIcon, locs, names, starts, appear, stops);
// A firemen
locs = new int[] { 25 };
names = new String[] { "FD", "FD 2", "FD 3" };
starts = new int[] { 400, 400, 400};
appear = new int[] { 400, 400, 400 };
stops = new int [][][] {{},{}, {}};
initLoop(manager, fireIcon, locs, names, starts, appear, stops);
:
: **** many additional definition sets omitted ****
:
}
|
And, finally, Listing 11 shows how to create an Alarm
entity. Obviously, we can easily add more alarms as we need them. Listing 11. Create alarms
/** Make some demo alarms */
static public void createAlarms(BuildingManager manager) {
final int alarms[] = { 12 };
LinkedList startPts = (LinkedList)manager.
getAvailableStartingPoints();
for (int i = 0; i < alarms.length; i++) {
manager.addEntity(
new Alarm(manager, (Point2D)startPts.get(alarms[i])));
}
}
|
Conclusion In this
article, we have shown you how to use lossless images, Swing technology,
and a custom animation engine for motion-path generation in 2D animation.
This method allows us to visualize the animation as we create it via
control paths in a quick and predictable way. Some of the advantages of
this technique are as follows:
- Ease of use
- Most image editors have a number of ways to generate straight lines,
rounded arcs, and other shapes. These options allow us to generate some
paths by hand quickly while reducing error. For some behaviors, this is
very useful.
- Reference images
- When the animation is moving relative to a background image (such as
in Figure
1), we may wish to move objects within the confines of items in that
image, for example keeping objects within the confines of hallways. Many
image editors will allow us to use a semi-transparent layer to generate
our control image over our background image. We then can easily create
our control paths to match our background image, as we can see both
images lined up while we generate the control image.
- Additive painting
- By blending colors, we are able to encode multiple behaviors at a
position. For example, using RGB colors we can use red (0xFF0000)to
represent the path to follow for one object and green (0x00FF00) to
encode the path to follow for another object. Using additive painting, a
point at which the paths intersect will be yellow (0xFFFF00).
With this additive model, when using, say, a 32-bit color model,
up to 32 different behaviors can be easily extracted from a given
position like a bitmask. Although we have only described one simple
behavior, the number of behaviors that can be encoded is limited only by
the number of bits assigned to each color in the image format.
We have also shown and described a simple animation engine for moving
objects around the sets of paths. Each object, called an
Entity and implemented as a JLabel in the
example, is driven periodically to update its position and/or appearance.
A long-running timer thread is assigned to drive this process. A
JPanel is used as the container of the objects and also as
the means of painting the background. See Resources for the
complete code for the animation example and the Java-based animation
engine introduced in this article.
Resources
- Download the source code for the
animation engine and the example animation sequence used in this
article.
- Visit java.sun.com to learn more about JFC and Swing.
- While you're at it, you might also want to read up on the Java 2D API and the Java Advanced Imaging
API.
- Mitch Goldstein's tutorial, Introduction to Java
2D offers a step-by-step guide to the advantages of advanced
drawing, text layout, and image manipulation that Java 2D brings to GUI
programming.
- Learn more about image creation and manipulation using the Java 2D
API with "Creating Java2D composites
for rollover effects" (developerWorks,
September 2002).
- The UK-based Technical Advisory Service for Images (TASI) has
provided a useful overview of the
various file formats and compression techniques for digital images,
including a section on lossless image compression techniques.
- Yakov Nekrich has compiled a good set of links about lossless image
compression.
- Learn more about some of the image types mentioned in this article
-- namely JPEGs, PNGs, and GIFs.
- Barry Feigenbaum has also written "Coding for
accessibility" (developerWorks,
October 2002), which shows you how to use Swing/JFC and a unique
Accessibility Toolkit to build more accessible Java
applications.
- You'll find hundreds of articles about every aspect of Java
programming in the developerWorks Java
technology zone.
- For a listing of free Java-based programming tutorials, see the developerWorks Java
tutorials page.
About the
authors Dr. Barry Feigenbaum is a member of the IBM
Worldwide Accessibility Center, where he is part of a team that
helps IBM make its products accessible to people with disabilities.
Dr. Feigenbaum has published several books and articles, holds
several patents, and has spoken at industry conferences such as
JavaOne. He serves as an Adjunct Assistant Professor of Computer
Science at the University of Texas, Austin. You can contact Dr.
Feigenbaum at feigenba@us.ibm.com. |
Tom Brunet is a graduate student at the
University of Wisconsin-Madison. He received his B.S. in Computer
Science from the University of Texas at Austin, where he was named
as a Dean's Honored Graduate. During his undergraduate studies, he
worked for IBM Research for four years, spending a summer with the
Data Abstraction Research group, and the remainder with the
Accessibility Center. He also worked concurrently as an
undergraduate research assistant for Dr. Nina Amenta. You can reach
Tom at tomab@cs.wisc.edu.
|
|
|