Introduction
This article presents a set of classes which allows you to draw pictures in a 3-dimensional coordinate space. Within this article, I'll touch on some of the mathematical concepts involved (it doesn't require anything more than what you learned in high school trigonometry), I'll explain the basics of how to use the library, and I'll list the resources I used in creating it.
Background
I recently became disturbingly obsessed with the motion of falling dominoes and the math that could describe it. I spent about two weeks scrawling calculations on the back of napkins and ATM receipts, and I covered the walls of my cubicle at work with increasingly refined problem descriptions and incomplete solutions. Once I had developed a working formula, I decided to implement it in code. This library is the end result of my attempt to animate a row of falling dominoes.
My domino calculations were based on relative positioning ("Turn right 45 degrees, then move forward 100 units") rather than absolute positioning ("Move to location [45, 12, 83]"). A language like LOGO is well suited to this kind of drawing, and my library has similar functionality. But because we're dealing in 3-dimensions, my library allows you to turn the cursor not only left and right, but also up (out of the screen, towards you) and down (into the screen, away from you).
Establishing Orientation
In order for the cursor to move around in 2-dimensions, it needs to be aware of both its location and its direction. Location can be established with a point [x,y], and we can establish direction with a vector. A vector establishes the cursor's forward direction, and is used to calculate the left and right directions.
In 3-dimensions, though, it's a bit trickier. The cursor's direction doesn't present us with all the information we need. Have a look at this example. Here are two airplanes, both flying in the same direction. Each airplane has a green guide pointing in the direction that it considers to be forward, a blue guide pointing towards what it considers to be right, and a purple guide pointing towards what it considers to be up.
Both of these planes are headed in the same direction, but if you told them both to turn right, they'd each do something different. That's why it's not enough to establish a direction in 3-dimensions. When moving around in 3D, we need to establish an orientation. Luckily, this is pretty easy. All we need is one extra vector. In addition to a vector pointing towards what we consider to be forward, we'll also need one pointing towards what we consider to be up. Once we establish two directions, we can use them to calculate all the other directions.
Using the code
The first class you need to become familiar with is Plotter3D
. The simplest constructor just takes a Graphics
object. This can be any valid Graphics
object, from a Form
, a Bitmap
, a Metafile
, etc.
using (Graphics g = this.CreateGraphics())
{
using (CPI.Plot3D.Plotter3D p = new CPI.Plot3D.Plotter3D(g))
{
}
}
Once you've created a Plotter3D
object, you can start drawing. Here's how you draw a square:
public void DrawSquare(Plotter3D p, float sideLength)
{
for(int i = 0; i < 4; i++)
{
p.Forward(sideLength);
p.TurnRight(90);
}
}
In this function, p.Forward(sideLength)
draws a side of the square, and p.TurnRight(90)
turns right 90 degrees. If we repeat this sequence four times, we'll have drawn a square, and we'll end up back at our starting point. The function draws a square that looks like this:
You can call the DrawSquare
multiple times, to create a cube:
public void DrawCube(Plotter3D p, float sideLength)
{
for (int i = 0; i < 4; i++)
{
DrawSquare(p, sideLength);
p.Forward(sideLength);
p.TurnDown(90);
}
}
Notice that in this function, we call p.TurnDown(90)
. This turns the cursor in 3D so that it's moving away from you, into the computer screen. When we draw four squares at different locations, we end up with a cube that looks something like this:
Rotation
Once you've defined a shape, it's pretty easy to rotate it. That's one of the benefits of using relative coordinates instead of absolute coordinates. To draw a rotated shape, move the cursor to the point you want to use as the center of the rotation, turn the cursor left or right or up or down as much as you'd like, then retrace your steps back to the starting point, and draw your shape. An example will probably help. Let's rotate our square:
public void DrawRotatedSquare(Plotter3D p, float sideLength, float rotationAngle)
{
p.PenUp();
p.Forward(sideLength / 2);
p.TurnRight(90);
p.Forward(sideLength / 2);
p.TurnLeft(90);
p.TurnRight(rotationAngle);
p.TurnLeft(90);
p.Forward(sideLength / 2);
p.TurnLeft(90);
p.Forward(sideLength / 2);
p.TurnRight(180);
p.PenDown();
DrawSquare(p, sideLength);
}
So if we were to call:
DrawRotatedSquare(p, 50, 45);
we'd get something that looks like this:
Using this technique, you can generate multiple images, each rotated a bit more than the last, to create animations like this:
This rotation logic also works in 3D, so you can use it to rotate 3D objects however you like:
Perspective
When displaying 3D images on a 2D computer screen, we need to account for perspective. I shamelessly lifted my perspective logic from Paresh Solanki's excellent CodeProject article: A short discussion on mapping 3D objects to a 2D display. In my implementation, the "screen" is the plane formed by the X and Y axes, and the "camera" is a Point3D
instance that you can specify in the constructor of a Plotter3D
object. If you don't specify a camera position, it will default to the (completely arbitrary) location [-30, 0, -600]. Experiment with changing the camera position, and see how it affects your drawings.
Questions Which Will Probably Be Frequently Asked...
How do I draw a circle?
Short answer...you don't. You can only draw lines. Longer answer...you can fake it pretty convincingly by drawing a polygon with a lot of sides. Here's an example function which will draw an approximation of a circle with a specified diameter:
private void DrawCircle(Plotter3D p, float diameter)
{
float radius = diameter / 2;
int sides = 64;
float innerAngle = 360F / sides;
float sideLength = (float)(radius *
Math.Sin(Orientation3D.DegreesToRadians(innerAngle) / 2) * 2);
Point3D initialLocation = p.Location;
Orientation3D initialOrientation = p.Orientation.Clone();
p.PenUp();
p.Forward(radius - (sideLength / 2));
p.PenDown();
for (int i = 0; i < sides; i++)
{
p.Forward(sideLength);
p.TurnRight(innerAngle);
}
p.Location = initialLocation;
p.Orientation = initialOrientation;
}
There are a couple of interesting things to notice about this function. First, it's actually drawing a polygon with 64 sides. If you increase the number of sides in the polygon, it'll draw a closer approximation of a circle, but you'll probably find that 64 is plenty in most cases.
You'll also notice that before we draw the circle, we save our location and orientation, and we restore it after we've drawn the circle. The reason for this is because this library does a lot of floating point math, and floating point math is imprecise. So even though we should end up back at our starting point after rotating 360 degrees, we can only really guarantee that we'll end up pretty close to the starting point. The errors caused by lack of precision are very small, but they could potentially add up after a while. So by saving our initial position and orientation beforehand, then restoring them afterwards, we can guarantee that our end point is exactly the same as our start point.
Finally, you'll notice that when saving the initial location, we simply call initialLocation = p.Location
, but when saving the initial orientation, we call initialOrientation = p.Orientation.Clone()
. We don't have to call Clone()
on p.Location
because p.Location
is a Point3D
, which is a value type, which means that initialLocation
gets populated with a copy of the value. In contrast, p.Orientation
is a reference type, which means that if we hadn't called Clone()
, initialOrientation
would have pointed to the same object as p.Orientation
, which is not the behavior we want. The Clone()
method creates a deep copy of the Orientation3D
object.
Let's try the function out.
DrawCircle(p, 100);
draws a circle that looks like this:
I'd say that it looks pretty circular, all things considered. All of the rotation and animation stuff that we've learned so far also applies to this object, so we could, for example, draw a series of circles to create a sphere, and then rotate it around, like this:
How do I draw a filled area?
You don't. You can only draw lines. As soon as you start doing anything fancier than lines, you need to start taking other things into account, like visibility determination, for example. That's beyond the scope of this project.
Why don't you mention Euler Angles, or Rotation Matrices, or Quaternions?
Because, all I really need this project to do is draw some lines in 3D, and that can be done entirely with vector arithmetic and some straightforward algebra and trigonometry. Of course, no discussion of 3D graphics is complete without mentioning a whole list of things that I haven't talked about, but this project isn't designed to be a complete 3D graphics package. This is just an easy-to-use method of visualizing and experimenting with 3D geometry. If you're looking for a high-powered, full-featured, 3D graphics library, have a look at DirectX or OpenGL.
So did you ever get around to animating dominoes, or what?
I sure did. The source code is included in the download, or you can look at the final result (exported to Flash) here.
Tips and Tricks
- Whenever you design a new shape, try to abstract it into a separate function (like
DrawCube
, DrawSphere
, etc.) rather than embedding the movements directly into your code. In some future revision, I'll probably create an abstract Shape
class that can be built upon.
- It's easy to lose your bearings while maneuvering the cursor around. Throughout the development cycle, I kept a toy airplane nearby and used it to keep track of the cursor's rotation. I encourage you to get a toy airplane as well, partly because it will help you visualize 3D movement, and partly because airplanes are awesome.
About the Math
There's a lot of math under the covers here. That makes sense, given that this whole project started as an attempt to visualize a math problem that I'd been working on. Most of the math involves vectors in one way or another, and you'd do well to learn about vector arithmetic if you want to mess with the code. I learned everything I know about vector math from the book 3D Math Primer for Graphics and Game Development, which is a very good introduction to 3D math. The book gives a geometric interpretation for almost all of the math it presents, which really helps you to visualize just exactly what you're doing. I've included references to individual pages of the book in the code's XML comments.
About the Unit Tests
I've included a relatively large battery of NUnit tests with this project. Remember that floating-point arithmetic is complicated. There are about half-a-zillion different corner cases that will trip you up if you're not paying attention. Apart from having to deal with things like Infinity and NaN, it's relatively easy to perform an operation which gives you a tiny loss of precision, but which quickly multiplies into a very large loss of precision. The included tests check the results of a wide range of inputs. By and large, I've dealt with these concerns, so you don't have to worry very much if you're just using the library as is. If you're planning on extending this library, however, it would be a good idea to extend the unit tests accordingly.
References
History
- June 7, 2006 - Initial posting.