Section 1.3
Transformations and Modeling
We now turn to another aspect of two-dimensional graphics in Java: geometric transformations. The material in this section will carry over nicely to three dimensions and OpenGL. It is an important foundation for the rest of the course.
1.3.1 Geometric Transforms
Being able to draw geometric shapes is important, but it is only part of the story. Just as important are geometric transforms that allow you to modify the shapes that you draw, such as by moving them, changing their size, or rotating them. Some of this, you can do by hand, by doing your own calculations. For example, if you want to move a shape three units to the right, you can simply add 3 to each of the horizontal coordinates that are used to draw the shape. This would quickly become tedious, however, so its nice that we can get the computer to do it for us simply by specifying an appropriate geometric transform. Furthermore, there are things that you simply can't do in Java without transforms. For example, when you draw a string of text without a transform, the baseline of the text can only be horizontal. There is no way to draw the string tilted to a 30-degree angle. Similarly, the only way to draw a tilted image is by applying a transform. Here is an example of a string and an image drawn by Java in their normal orientation and with a counterclockwise 30-degree rotation:
Every Graphics2D graphics context has a current transform that is applied to all drawing that is done in that context. You can change the transform, and the new value will apply to all subsequent drawing operations; it will not affect things that have already been drawn in the graphics context. Generally, in a newly created Graphics2D, the current transform is the identity transform, which has no effect at all on drawing. (There are some exceptions to this. For example, when drawing to a printer, the pixels are very small. If the same coordinates are used when drawing to the printer as are used on the screen, the resulting picture would be tiny. So, when drawing to a printer, Java provides a default transform that magnifies the picture to make it look about the same size as it does on the screen.)
The transform in a Graphics2D can be any affine transform. An affine transform has the property that when it is applied to any set of parallel lines, the result is also a set of parallel lines (or, possibly, a single line or even a single point). An affine transform in two dimensions can be specified by six numbers a, b, c, d, e, and f, which have the property that when the transform is applied to a point (x,y), the resulting point (x1,y1) is given by the formulas:
x1 = a*x + b*y + e y1 = c*x + d*y + f
Affine transforms have the important property that if you apply one affine transform and then follow it with a second affine transform, the result is another affine transform. For example, moving each point three units to the right is an affine transform, and so is rotating each point by 30 degrees about the origin (0,0). Therefore the combined transform that first moves a point to the right and then rotates it is also an affine transform. Combining two affine transformations in this way is called multiplying them (although it is not multiplication in the usual sense). Mathematicians often use the term composing rather than multiplying.
It's possible to build up any two-dimensional affine transformation from a few basic kinds of simple transforms: translation, rotation, and scaling. It's good to have an intuitive understanding of these basic transforms and how they can be used, so we will go through them in some detail. The effect of a transform depends on what coordinate system you are using for drawing. For this discussion, we assume that the origin, (0,0), is at the center of the picture, with the positive direction of the x-axis pointing to the right and the positive direction of the y-axis pointing up. Note that this orientation for the y-axis is the opposite of the usual orientation in Java; it is, however, the more common orientation in mathematics and in OpenGL.
A translation simply moves each point by a certain amount horizontally and a certain amount vertically. The formula for a translation is
x1 = x + e y1 = y + f
where the point (x,y) is moved e units horizontally and f units vertically. (Thus for a translation, a = d = 1, and b = c = 0.) If you wanted to apply this translation to a Graphics2D g, you could simply say g.translate(e,f). This would mean that for all subsequent drawing operations, e would be added to the x-coordinate and f would be added to the y-coordinate. Let's look at an example. Suppose that you are going to draw an "F" centered at (0,0). If you say g.translate(4,2) before drawing the "F", then every point of the "F" will be moved over 4 units and up 2 units, and the "F" that appears on the screen will actually be centered at (4,2). Here is a picture:
The light gray "F" in this picture shows what would be drawn without the translation; the dark red "F" shows the same "F" drawn after applying a translation by (4,2). The arrow shows that the upper left corner of the "F" has been moved over 4 units and up 2 units. Every point in the "F" is subjected to the same displacement.
Remember that when you say g.translate(e,f), the translation applies to all the drawing that you do after that, not just to the next shape that you draw. If you apply another transformation to the same g, the second transform will not replace the translation. It will be multiplied by the translation, so that subsequent drawing will be affected by the combined transformation. This is an important point, and there will be a lot more to say about it later.
A rotation rotates each point about the origin, (0,0). Every point is rotated through the same angle, called the angle of rotation. For this purpose, angles in Java are measured in radians, not degrees. For example, the measure of a right angle is π/2, not 90. Positive angles move the positive x-axis in the direction of the positive y-axis. (This is counterclockwise in the coordinate system that we are using here, but it is clockwise in the usual pixel coordinates, where the y-axis points down rather than up.) Although it is not obvious, when rotation through an angle of r radians about the origin is applied to the point (x,y), then the resulting point (x1,y1) is given by
x1 = cos(r) * x - sin(r) * y y1 = sin(r) * x + cos(r) * y
That is, in the general formula for an affine transform, e = f = 0, a = d = cos(r), b = −sin(r), and c = sin(r). Here is a picture that illustrates a rotation about the origin by the angle −3π/4:
Again, the light gray "F" is the original shape, and the dark red "F" is the shape that results if you apply the rotation. The arrow shows how the upper left corner of the original "F" has been moved.
In a Graphics2D g, you can apply a rotation through an angle r by saying g.rotate(r) before drawing the shapes that are to be rotated. We are now in a position to see what can happen when you combine two transformations. Suppose that you say
g.translate(4,0); g.rotate(Math.PI/2);
before drawing some shape. The translation applies to all subsequent drawing, and the thing that you draw after the translation is a rotated shape. That is, the translation applies to a shape to which a rotation has already been applied. An example is shown on the left in the illustration below, where the light gray "F" is the original shape. The dark red "F" shows the result of applying the two transforms. The "F" has first been rotated through a 90 degree angle, and then moved 4 units to the right.
Transforms are applied to shapes in the reverse of the order in which they are given in the code (because the first transform in the code is applied to a shape that has already been affected by the second transform). And note that the order in which the transforms are applied is important. If we reverse the order in which the two transforms are applied in this example, by saying
g.rotate(Math.PI/2); g.translate(4,0);
then the result is as shown on the right in the above illustration. In that picture, the original "F" is first moved 4 units to the right and the resulting shape is then rotated through an angle of π/2 about the origin to give the shape that actually appears on the screen.
For a useful application of using several transformations, suppose that we want to rotate a shape through an angle r about a point (p,q) instead of about the point (0,0). We can do this by first moving the point (p,q) to the origin with g.translate(−p,−q). Then we can do a standard rotation about the origin by calling g.rotate(r). Finally, we can move the origin back to the point (p,q) using g.translate(p,q). Keeping in ming that we have to apply the transformations in the reverse order, we can say
g.translate(p,q); g.rotate(r); g.translate(-p,-q);
before drawing the shape. In fact, though, you can do the same thing in Java with one command: g.rotate(r,p,q) will apply a rotation of r radians about the point (p,q) to subsequent drawing operations.
A scaling transform can be used to make objects bigger or smaller. Mathematically, a scaling transform simply multiplies each x-coordinate by a given amount and each y-coordinate by a given amount. That is, if a point (x,y) is scaled by a factor of a in the x direction and by a factor of d in the y direction, then the resulting point (x1,y1) is given by
x1 = a * x y1 = d * y
If you apply this transform to a shape that is centered at the origin, it will stretch the shape by a factor of a horizontally and d vertically. Here is an example, in which the original light gray "F" is scaled by a factor of 3 horizontally and 2 vertically to give the final dark red "F":
The common case where the horizontal and vertical scaling factors are the same is called uniform scaling. Uniform scaling stretches or shrinks a shape without distorting it. Note that negative scaling factors are allowed and will result in reflecting the shape as well as stretching or shrinking it.
When scaling is applied to a shape that is not centered at (0,0), then in addition to being stretched or shrunk, the shape will be moved away from 0 or towards 0. In fact, the true description of a scaling operation is that it pushes every point away from (0,0) or pulls them towards (0,0). (If you want to scale about a point other than (0,0), you can use a sequence of three transforms, similar to what was done in the case of rotation. Java provides no shorthand command for this operation.)
To scale by (a,d) in Graphics2D g, you can call g.scale(a,d). As usual, the transform applies to all x and y coordinates in subsequent drawing operations.
We will look at one more type of basic transform, a shearing transform. Although shears can in fact be built up out of rotations and scalings if necessary, it is not really obvious how to do so. A shear will "tilt" objects. A horizontal shear will tilt things towards the left (for negative shear) or right (for positive shear). A vertical shear tilts them up or down. Here is an example of horizontal shear:
A horizontal shear does not move the x-axis. Every other horizontal line is moved to the left or to the right by an amount that is proportional to the y-value along that line. When a horizontal shear is applied to a point (x,y), the resulting point (x1,y1) is given by
x1 = x + b * y y1 = y
for some constant shearing factor b. Similarly, a vertical shear by a shearing factor c has equations
x1 = x y1 = c * x + y
In Java, the method for applying a shear to a Graphics2D g allows you to specify both a horizontal shear factor b and a vertical shear factor c: g.shear(b,c). For a pure horizontal shear, you can set c to zero; for a pure vertical shear, set b to zero.
In Java, an affine transform is represented by an object belonging to the class java.awt.geom.AffineTransform. The current transform in a Graphics2D g is an object of type AffineTransform. Methods such as g.rotate and g.shear multiply that current transform object by another transform. There are also methods in the AffineTransform class itself for multiplying the transform by a translation, a rotation, a scaling, or a shear. Usually, however, you will just use the corresponding methods in the Graphics2D class.
You can retrieve the current affine transform from a Graphics2D g by calling g.getTransform(), and you can replace the current transform by calling g.setTransform(t). In general, it is recommended to use g.setTransform only for restoring a previous transform in g, after temporarily modifying it.
One use for an AffineTransform is to compute the inverse transform. The inverse transform for a transform t is a transform that exactly reverses the effect of t. Applying t followed by the inverse of t has no effect at all -- it is the same as the identity transform.
Not every transform has an inverse. For example, there is no way to undo a scaling transform that has a scale factor of zero. Most transforms, however, do have inverses. For the basic transforms, the inverses are easy. The inverse of a translation by (e,f) is a translation by (−e,−f). The inverse of rotation by an angle r is rotation by −r. The inverse of scaling by (a,d) is scaling by (1/a,1/d), provided a and d are not zero.
For a general AffineTransform t, you can call t.createInverse() to get the inverse transform. This method will throw an exception of type NoninvertableTransformException if t does not have an inverse. This is a checked exception, which requires handling, for example with a try..catch statement.
Sometimes, you might want to know the result of applying a transform or its inverse to a point. For an AffineTransform t and points p1 and p2 of type Point2D, you can call
t.transform( p1, p2 );
to apply t to the point p1, storing the result of the transformation in p2. (It's OK for p1 and p2 to be the same object.) Similarly, to apply the inverse transform of t, you can call
t.inverseTransform( p1, p2 );
This statement will throw a NoninvertibleTransformException if t does not have an inverse.
You have probably noticed that we have discussed two different uses of transforms: Coordinate transforms change the coordinate system that is used for drawing. Modeling transforms modify shapes that are drawn. In fact, these two uses are two sides of the same coin. The same transforms that are used for one purpose can be used for the other. Let's see how to set up a coordinate system on a component such as a JPanel. In the standard coordinate system, the upper left corner is (0,0), and the component has a width and a height that give its size in pixels. The width and height can be obtained by calling the panel's getWidth and getHeight methods. Suppose that we would like to draw on the panel using a real-number coordinate system that extends from x1 on the left to x2 on the right and from y1 at the top to y2 at the bottom. A point (x,y) in these coordinates corresponds to pixel coordinates
( (x-x1)/(x2-x1) * width, (y-y1)/(y2-y1) * height )
To see this, note that (x-x1)/(x2-x1) is the distance of x1 from the left edge of the panel, given as fraction of the total width, and similarly for the height. If we rewrite this as
( (x-x1) * (width/(x2-x1)), (y-y1) * (height/(y2-y1)) )
we see that the change in coordinates can be accomplished by first translating by (-x1,-y1) and then scaling by (width/(x2-x1),height/(y2-y1)). Keeping in mind that transforms are applied to coordinates in the reverse of the order in which they are given in the code, we can implement this coordinate transform in the panel's paintComponent method as follows
protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D)g.create(); g2.scale( getWidth() / ( x2 - x1 ), getHeight() / ( y2 - y1) ); g2.translate( -x1, -y1 ); ... // draw the content of the panel, using the new coordinate system }
The scale and translate in this example set up the coordinate transformation. Any further transforms can be thought of as modeling transforms that are used to modify shapes. However, you can see that it's really just a nominal distinction.
The transforms used in this example will work even in the case where y1 is greater than y2. This allows you to introduce a coordinate system in which the minimal value of y is at the bottom rather than at the top.
One issue that remains is aspect ratio, which refers to the ratio between the height and the width of a rectangle. If the aspect ratio of the coordinate rectangle that you want to display does not match the aspect ratio of the component in which you will display it, then shapes will be distorted because they will be stretched more in one direction than in the other. For example, suppose that you want to use a coordinate system in which both x and y range from 0 to 1. The rectangle that you want to display is a square, which has aspect ratio 1. Now suppose that the component in which you will draw has width 800 and height 400. The aspect ratio for the component is 0.5. If you simply map the square onto the component, shapes will be stretched twice as much in the horizontal direction as in the vertical direction. If this is a problem, a solution is to use only part of the component for drawing the square. We can do this, for example, by padding the square on both sides with some extra x-values. That is, we can map the range of x values between −0.5 and 1.5 onto the component, while still using range of y values from 0 to 1. This will place the square that we really want to draw in the middle of the component. Similarly, if the aspect ratio of the component is greater than 1, we can pad the range of y values.
The general case is a little more complicated. The applyLimits method, shown below, can be used to set up the coordinate system in a Graphics2D. It should be called in the paintCompoent method before doing any drawing to which the coordinate system should apply. This method is used in the sample program CubicBezierEdit.java, so you can look there for an example of how it is used. When using this method, the parameter limitsRequested should be an array containing the left, right, top, and bottom edges of the coordinate rectangle that you want to display. If the preserveAspect parameter is true, then the actual limits will be padded in either the horizontal or vertical direction to match the aspect ratio of the coordinate rectangle to the width and height of the "viewport." (The viewport is probably just the entire component where g2 draws, but the method could also be used to map the coordinate rectangle onto a different viewport.)
/** * Applies a coordinate transform to a Graphics2D graphics context. The upper * left corner of the viewport where the graphics context draws is assumed to * be (0,0). This method sets the global variables pixelSize and transform. * * @param g2 The drawing context whose transform will be set. * @param width The width of the viewport where g2 draws. * @param height The height of the viewport where g2 draws. * @param limitsRequested Specifies a rectangle that will be visible in the * viewport. Under the transform, the rectangle with corners (limitsRequested[0], * limitsRequested[1]) and (limitsRequested[2],limitsRequested[3]) will just * fit in the viewport. * @param preserveAspect if preserveAspect is false, then the limitsRequested * rectangle will exactly fill the viewport; if it is true, then the limits * will be expanded in one direction, horizontally or vertically, to make * the aspect ratio of the displayed rectangle match the aspect ratio of the * viewport. Note that when preserveAspect is false, the units of measure in * the horizontal and vertical directions will be different. */ private void applyLimits(Graphics2D g2, int width, int height, double[] limitsRequested, boolean preserveAspect) { double[] limits = limitsRequested; if (preserveAspect) { double displayAspect = Math.abs((double)height / width); double requestedAspect = Math.abs(( limits[3] - limits[2] ) / ( limits[1] - limits[0] )); if (displayAspect > requestedAspect) { double excess = (limits[3] - limits[2]) * (displayAspect/requestedAspect - 1); limits = new double[] { limits[0], limits[1], limits[2] - excess/2, limits[3] + excess/2 }; } else if (displayAspect < requestedAspect) { double excess = (limits[1] - limits[0]) * (requestedAspect/displayAspect - 1); limits = new double[] { limits[0] - excess/2, limits[1] + excess/2, limits[2], limits[3] }; } } g2.scale( width / (limits[1]-limits[0]), height / (limits[3]-limits[2]) ); g2.translate( -limits[0], -limits[2] ); double pixelWidth = Math.abs(( limits[1] - limits[0] ) / width); double pixelHeight = Math.abs(( limits[3] - limits[2] ) / height); pixelSize = (float)Math.min(pixelWidth,pixelHeight); transform = g2.getTransform(); }
The last two lines of this method assign values to pixelSize and transform, which are assumed to be global variables. These are values that might be useful elsewhere in the program. For example, pixelSize might be used for setting the width of a stroke to some given number of pixels:
g2.setStroke( new BasicStroke(3*pixelSize) );
Remember that the unit of measure for the width of a stroke is the same as the unit of measure in the coordinate system that you are using for drawing. If you want a stroke -- or anything else -- that is measured in pixels, you need to know the size of a pixel.
As for the transform, it can be used for transforming points between pixel coordinates and drawing coordinates. In particular, suppose that you want to implement mouse interaction. The methods for handling mouse events will tell you the pixel coordinates of the mouse position. Sometimes, however, you need to know the drawing coordinates of that point. You can use the transform variable to make the transformation. Suppose that evt is a MouseEvent. You can use the transform variable from the above method in the following code to transform the point from the MouseEvent into drawing coordinates:
Point2D p = new Point2D.Double( evt.getX(), evt.getY() ); transform.inverseTransform( p, p );
The point p will then contain the drawing coordinates corresponding to the mouse position.
1.3.2 Hierarchical Modeling
A major motivation for introducing a new coordinate system is that it should be possible to use the coordinate system that is most natural to the scene that you want to draw. We can extend this idea to individual objects in a scene: When drawing an object, it should be possible to use the coordinate system that is most natural to the object. Geometric transformations allow us to specify the object using whatever coordinate systeme we want, and to apply a transformation to the object to place it wherever we want it in the scene. Transformations used in this way are called modeling transformations.
Usually, we want an object in its natural coordinates to be centered at the origin, (0,0), or at least to use the origin as a convenient reference point. Then, to place it in the scene, we can use a scaling transform, followed by a rotation, followed by a translation to set its size, orientation, and position in the scene. Since scaling and rotation leave the origin fixed, those operations don't move the reference point for the object. A translation can then be used to move the reference point, and the object along with it, to any other point. (Remember that in the code, the transformations are specified in the opposite order from the order in which they are applied to the object and that the transformations are specified before drawing the object.)
The modeling transformations that are used to place an object in the scene should not affect other objects in the scene. To limit their application to just the one object, we can save the current transformation before starting work on the object and restore it afterwards. The code could look something like this, where g is a Graphics2D:
AffineTransform saveTransform = g.getTransform(); g.translate(a,b); // move object into position g.rotate(r); // set the orientation of the object g.scale(s,s); // set the size of the object ... // draw the object, using its natural coordinates g.setTransform(saveTransform); // restore the previous transform
Note that we can't simply assume that the original transform is the identity transform. There might be another transform in place, such as a coordinate transform, that affects the scene as a whole. The modeling transform for the object is effectively applied in addition to any other transform that was specified previously. The modeling transform moves the object from its natural coordinates into its proper place in the scene. Then on top of that, another transform is applied to the scene as a whole, carrying the object along with it.
Now let's extend this a bit. Suppose that the object that we want to draw is itself a complex picture, made up of a number of smaller objects. Think, for example, of a potted flower made up of pot, stem, leaves, and bloom. We would like to be able to draw the smaller component objects in their own natural coordinate systems, just as we do the main object. But this is easy: We draw each small object in its own coordinate system, and use a modeling transformation to move the small object into position within the main object. On top of that, we can apply another modeling transformation to the main object, to move it into the completed scene; its component objects are carried along with it. In fact, we can build objects that are made up of smaller objects which in turn are made up of even smaller objects, to any level. This type of modeling is known as hierarchical modeling.
Let's look at a simple example. Suppose that we want to draw a simple 2D image of a cart with two wheels. We will draw the body of the cart as a rectangle. For the wheels, suppose that we have written a method
private void drawWheel(Graphics2D g2)
that draws a wheel. The wheel is drawn using its own natural coordinate system. Let's say that in this coordinate system, the wheel is centered at (0,0) and has radius 1.
We will draw the cart with the center of its rectangular body at the point (0,0). The rectangle has width 5 and height 2, so its corner is at (−2.5,0). To complete the cart, we need two wheels. To make the size of the wheels fit the cart, we will probably have to scale them. To place them in the correct positions relative to body of the cart, we have to translate one wheel to the left and one wheel to the right. When I coded this example, I had to play around with the numbers to get the right sizes and positions for the wheels, and I also found that the wheels looked better if I also moved them down a bit. Using the usual techniques of hierarchical modeling, we have to remember to save the current transform and to restore it after drawing each wheel. Here is a subroutine that can be used to draw the cart:
private void drawCart(Graphics2D g2) { AffineTransform tr = g2.getTransform(); // save the current transform g2.translate(-1.5,-0.1); // center of first wheel will be at (-1.5,-0.1) g2.scale(0.8,0.8); // scale to reduce radius from 1 to 0.8 drawWheel(g2); // draw the first wheel g2.setTransform(tr); // restore the transform g2.translate(1.5,-0.1); // center of second wheel will be at (1.5,-0.1) g2.scale(0.8,0.8); // scale to reduce radius from 1 to 0.8 drawWheel(g2); // draw the second wheel g2.setTransform(tr); // restore the transform g2.setColor(Color.RED); g2.fill(new Rectangle2D.Double(-2.5,0,5,2) ); // draw the body of the cart }
It's important to note that the same subroutine is used to draw both wheels. The reason that two wheels appear in the picture is that different modeling transformations are in effect for the two subroutine calls. Once we have this cart-drawing subroutine, we can use it to add a cart to a scene. When we do this, we can apply another modeling transformation to the cart as a whole. Indeed, we could add several carts to the scene, if we want, by calling the cart subroutine several times with different modeling transformations.
You should notice the analogy here: Building up a complex scene out of objects is similar to building up a complex program out of subroutines. In both cases, you can work on pieces of the problem separately, you can compose a solution to a big problem from solutions to smaller problems, and once you have solved a problem, you can reuse that solution in several places.
Here is our cart used in a scene. This applet actually shows an animation in which the cart rolls down the road while windmills turn in the background.
You can probably see how hierarchical modeling is used to draw the windmills in this example. There is a drawWindmill method that draws a windmill in its own coordinate system. Each of the windmills in the scene is then produced by applying a different modeling transform to the standard windmill.
It might not be so easy to see how different parts of the scene can be animated. In fact, animation is just another aspect of modeling. A computer animation consists of a sequence of frames. Each frame is a separate image, with small changes from one frame to the next. From our point of view, each frame is a separate scene and has to be drawn separately. The same object can appear in many frames. To animate the object, we can simply apply a different modeling transformation to the object in each frame. The parameters used in the transformation can be computed from the current time or from the frame number. To make a cart move from left to right, for example, we might apply a translation
g2.translate(frameNumber * 0.1);
to the cart, where frameNumber is the frame number, which increases by 1 from one frame to the next. (The animation is driven by a Timer that fires every 30 milliseconds. Each time the timer fires, 1 is added to frameNumber and the scene is redrawn.) In each frame, the cart will be 0.1 units farther to the right than in the previous frame. (In fact, in the actual program, the translation that is applied to the cart is
g2.translate(-3 + 13*(frameNumber % 300) / 300.0, 0);
which moves the reference point of the cart from −3 to 13 along the horizontal axis every 300 frames.)
The really neat thing is that this type of animation works with hierarchical modeling. For example, the drawWindmill method doesn't just draw a windmill -- it draws an animated windmill, with turning vanes. That just means that the rotation applied to the vanes depends on the frame number. When a modeling transformation is applied to the windmill, the rotating vanes are scaled and moved as part of the object as a whole. This is actually an example of hierarchical modeling. The vanes are sub-objects of the windmill. The rotation of the vanes is part of the modeling transformation that places the vanes into the windmill object. Then a further modeling transformation can be applied to the windmill object to place it in the scene.
The file HierarchicalModeling2D.java contains the complete source code for this example. You are strongly encouraged to read it, especially the paintComponent method, which composes the entire scene.