Here I will present a basic 3D system (rotating, lit cube) and add other extensions in further tutorials. I'll explain everything that needs to be done to get the final product, and there are explanatons in the source code too. It's not optimized, so that you can see what's really going on.

Display


The first requirement for 3D graphics is to be able to display something. I used VBE 2 for SVGA resolution (that means you need UNIVBE 5.1 or later, and compile the code with svga.c, ie: gcc 3d.c svga.c -o 3d.exe ...). My code's somewhat faster than Allegro, so I used it, because no complicated functions are required for these purposes. If high resolution is too slow for you, then use mode 13h, but it's not worth it for now, since we're only displaying a single cube. All you need is a 486 with a local bus video card.

Basics


You need to understand a few concepts that come up in 3D frequently. Three dimensional space has length, width and depth. These can be described with a coordinate system containing (x, y, z) values corresponding to each dimension. These values mean the displacement of a point along each 'axis'. An axis is an imaginary line running horizontally left or right, vertically, or horizontally in and out of the screen. These are all perpendicular to each other and can be described using 'vectors'. A vector is described by (x, y, z) coordinates and is thought of as a line running from (0, 0, 0) through the specified coordinate. This way the x axis is represented by (1, 0, 0), ie. a line from (0, 0, 0) (also called the origin) going 1 unit to the right. The same is true for the other axes. A 'vertex' is a point in space also described by (x, y, z) coordinates which symbolize the amount of displacement along each of the axes. Vertices are used to describe an object and are points where two or more lines on the object's surface meet.

Projection


So let's begin with simple points (single vertex) first. The whole foundation of 3D graphics is to be able to plot a 3D point (which describes a 3d object) on a 2D screen. This is done by using the depth or z value to affect the x,y coordinates, ie: by dividing each by z and plotting the result. This has the effect of creating less displacement as the depth increases, called perspective. Objects farther away appear smaller.

The view point is usually at the origin, looking straight into the screen along the z axis. Even if your application requires the camera to move around and rotate, you actually move and rotate the whole world around it (all the objects at the same time). This is to simplify the perspective projection function. It looks something like this:

screen.x = screen_center.x + distance*(vertex.x / vertex.z)
screen.y = screen_center.y - distance*(vertex.y / vertex.z)

where:
screen_center is the coordinate of the middle point on the screen, ie: (x_res/2, y_res/2). If you didn't use this, you would never see negative coordinates - not good.
distance is a constant that specifies how far the imaginary view point (the phisical viewer) is behind the view plane (screen), or the 'field of view' which it translates to - smaller numbers give a wider FOV (you can see more through a keyhole if you're closer to it). I find 256 or 512 to give good results, and the multiplication gets nicely optimized.

Rotating the world is one of the reasons why you separate your object's coordinates from what you see on the screen. You should usually create your objects relative to a fixed center (0, 0, 0), so a box would be defined by two of its corners at (-1, -1, -1) and (1, 1, 1). As you can see, the object's center is at (0, 0, 0). These are called object coordinates, and are going to stay the same. You use them to get the world coordinates after you transform them to the appropriate location. For animations you transform them by different amounts to give the sense of movement. This is another reason for not changing the object coordinates: after too many transformations the coordinates would get screwed up by rounding errors, so that even if you rotate them a full 360 degrees, you won't end up where you started. Ok, so the world coordinates are the ones that you are going to finally display on the screen after perspective projection.

Transformations


But how do you move 3D points around (transform them)? Simple displacement or translation is done by adding or subrtacting from the point's coordinates. If you want to move a point to the right you add 1 to its x coordinate, for example. You can also scale an object by multiplying each of its vertices by a given value. Rotation is a bit more complicated, because you have to use sine and cosine functions on each coordinate. The rotation occurs around the origin and is done separately around each axis by the given angle. This means that you have to work with object coordinates, as for scaling. The order of rotations around each axis also matters. Here's the algorythm:

x, y, z are the coordinates of the point to be rotated,
ax, ay, az are the angles the object is to be rotated by around each axis,
rx, ry, rz are the rotated coordinates

ry = y*cos(ax) - z*sin(ax) // rotation around the x axis
rz = z*cos(ax) + y*sin(ax)

rz = rz*cos(ay) - x*sin(ay) // around y
rx = x*cos(ay) + rz*sin(ay)
              ^ note: this rz is the one you started with, not the one
                you got from the previous line; same goes for the next axis
rx = rx*cos(az) - ry*sin(az) // around z
ry = ry*cos(az) + rx*sin(az)

I think a very simple optimization won't hurt here. Sin and cos are calculated very slowly by the fpu, and if you don't have one, it's even worse. What you can do is set up an array of values that contain the sines and cosines of their indexes. It's done very simply with a for loop:

double sin[360]

for(a=0; a<360; a++)
sin[a]=sin(a);

Now all you do is use sin[angle] instead of sin(angle). Of course, you'd need a different name for the array to avoid conflicts. Another thing: the above statements won't work in actual code, because the sin() function takes angles in radians, not normal degrees (there are 2pi radians in a full circle). This is another advantage of the lookup table, you can work in degrees. Well, not exactly... In the code I used an array of 256 values, simply because they can be described by an 8 bit index which wraps around at every full circle, ie: 365º is really 5º. The only difference is that you will work with different angles, for example: 90º is 64, 180º is 128, etc. If you wanted to use an array of 360 values, then you'd have to go: sin[angle%360] which is much slower, so that you don't go over the limit of the array. All these transformations should be done on object coordinates, and the result stored in world coords. There is also a way to do all of the transformations, even the projection, at once using
matrices, but it's not necessary for the current application.

Now that you have points all over space, and you're able to move them around, they can be used to form coherent objects. As I said before, an object should be defined as centered at the origin, for reasons I've already talked about. Objects are usually defined by points at their corners or on their surface. These points are called 'vertices' (vertex in singular). Neighbouring vertices are collected and connected using lines for wireframe display or used as vertices of 'polygons' for solids.

Polygon Scan Conversion


Polygons are two-dimentional (flat) shapes bounded by straight lines going from one vertex to the other. Usually triangles are used, because they always define a flat surface. For the example I used quadrilaterals, since they can be used more intuitively for a cube. By the way, polygons are plotted in two dimensions, 3D vertices are first converted to 2d using perspective projection shown above. There are many ways to draw polygons, but the main idea is to find the lines connecting the vertices and fill in the inside. The way you find an edge between two points is by using the good old y=mx+b line equation from highschool math class. In case you forgot (I didn't, cause I learned it last year :-), this equation uses an x coordinate to find a y on the line. 'm' stands for the slope of the line and b for its y-intercept (the point where x is 0). Basically all we need from here is the slope. It is defined by rise/run, ie: delta_y/delta_x; but we need x in function of y, for reasons I will explain later. Since you have the two endpoints (x1,y1) and (x2,y2), you can easily get the slope by:

m=(x2-x1)/(y2-y1)

This gives you the amount by which x changes every time you increase y by one. Now all you need to do is start at the first coordinate and each time you increment y, add m to x. It would look something like this.

   m=(x2-x1)/(y2-y1);
   x=x1;

   for(y=y1; y < y2; y++)
   {
      edge[y]=x;
      x+=m;
   }

Now I'll tell you why we needed the x coordinates. The reason is the way you fill the poly: you draw horizontal lines at each y coordinate, and you need the two endpoints. It's done with horizontal lines instead of vertical because it's much easier and faster to program. But wait! You only have one side so far. What you need to do is set up two arrays, one for the left edge(s) and one for the right. As you trace each edge, put it on the appropriate side. This is a bit tricky. I'll explain the triangles first.

First you need to sort the vertices by height. Now you can trace an edge from the top to the bottom vertex in one edge buffer, and from 1 to 2 and 2 to 3 in the other buffer. The problem here is that you don't know which side each edge should go into. Its only implication is that you might have to swap the endpoints when you're drawing your horizontal line. The other thing you can do is find the point on the long edge which is at the same height as the middle vertex. If you work out the math, this becomes:

m=(V0.x-V2.x)/(V0.y-V2.y)
x=V2.x+(V2.y-V1.y)*m



Now you subtract the middle vertex's x coordinate from the one you just got, and if the result is negative, the middle point is to the right, otherwise to the left of the long side. You use this to decide which sides belong to which edge buffer. If for some reason you can't implement it, one of the examples on the
shading page does.

For polygons with more sides, you go around from one vertex to the next and trace the edges. There is no easy way to automatically put them in the right edge buffer, so you have to decide while you're tracing the side. If there was no other edge at a particular y coordinate, then the present line's x coordinate is put into both buffers. If there was another line at the particular location in the buffer you're trying to write, then you only replace the existing value if your present edge's x coordinate is more to the right, or more to the left, depending on which side it is. This means that if you have a point at 100 in both buffers and your point is 250, then you write your value to the right buffer, since it is more to the right then 100. On the other hand you can't write to the left one, because the value already present is more to the left.

   int left_edge[scr_y_res], right_edge[scr_y_res];

for (y=y1; y < y2; y++) { if(xright_edge[y]) right_edge[y]=(int)x; x+=m; }

This method can be used for polygons with any number of vertices, as long as they are not concave (ie: four or more sides going through the same y coordinate). Now all you have left to do is connect the two edges with a horizontal line at every y coordinate on the screen. This second method is too slow for triangles though, because of all the checks while filling the buffers, so don't use it. In general there is no reason to use anything other than triangles.

Now that you have two edge buffers, you just draw the horizontal lines from one to the other. You start at the top and work downwards:

   for(y=V0.y; y < V2.y; y++)  // for triangle with vertices sorted by height
   {
      x1=left_edge[y].x;
      x2=right_edge[y].x;
   
      for(x=x1; x < x2; x++)
      {
         putpixel(x, y, c);
      }
   }

Sorting and Backface Removal


But your object still doesn't look solid at all. That's because the back sides show up in front half the time. A logical solution is arranging the polys so that back sides stay at the back. You can sort the sides by their z value and then draw them back to front. You need to use the average z value across the polygon. You get this by adding all the z values together and dividing the result by the number of vertices of the poly. You can use any sorting algorythm you want. This still doesn't fix everything, especially with few sided polyhedrons like my cube. The next method will correct everything and also speed things up. This is about finding out if the polygon is facing towards the viewer or away. It's called 'backface culling' and is done by finding out if the vertices of the poly are in clockwise order. For this reason you should create your objects with the vertices in the right order. What you need is two vectors originating fron the same point. You get these by choosing three consecutive points and subtracting the middle one from the other two. Now you basically use the z part of the cross product (explained below) of the two vectors, which is either positive or negative, according to the points being in clockwise or anticlockwise order. You only draw the sides that are facing you, so at least half of them will always be invisible (for closed objects), saving display time. The equation is:

if ((x1*y2 - x2*y1) > 0) then clockwise

Shading


So you have a solid looking object moving around... But not quite. To make it look more realistic, you have to make the colors of its sides respond to a light source, ie: if the light rays hit the side perpendicularly it is the brightest. The brightness decreases until the polygon's edge-on to the light source when it recieves no light, same thing if it's totally facing away. But how do you find out which way it's facing? Now the 'surface normals' come into play. These are vectors that are perpendicular to the plane of the poly. So how do you get that? You use another mathematical operation called the 'cross product' (×). The cross product of two vectors is always perpendicular to both of them. This how you find it:

A and B are vectors whose cross product is C (also a vector)

A × B = C
Cx=Ay*Bz - By*Az
Cy=Az*Bx - Bz*Ax
Cz=Ax*By - Bx*Ay

To find the normal of a poly, you take 3 consecutive vertices in clockwise order (the order matters, otherwise you get a vector pointing the opposite way). You subtract the middle one from the other two and you get two vectors which you can use in the cross product. Great! Now you got a vector perpendicular to your poly. But for some reason this is not the end. You should also make it of length 1, that's why it's called a normal! You use the Pythagoran theorem (l=sqrt(delta_x²+delta_y²) to find its length, then divide each dimention by it. Since a vector always originates at 0, you don't need delta values.

l=sqrt(x²+y²+z²)
normal=(x/l, y/l, z/l)

This a slow process especially because of the square root and divisions. You calculate normals in object space when you initialize your object, and rotate them together with the vertices, because you can't calculate them every time you wanna display it. So now you need to find the angle between the normal and the light vector... Another new operation: the 'dot product' (•). This one's really simple:

A • B = (Ax*Bx + Ay*By + Az*Bz)
cos(angle)=(N • L) / (|N| * |L|)

where |N| is the length of vector N - the normal

Remember that we made the normals of length one, so you just did away with the division! Also notice that the result is the cosine of the actual angle. This is quite helpful, because it means that the possible values range from -1 to 1. Since arccos(1) is zero degrees, we know that it means maximum light intensity. 0 is 90 degrees and negative nubmers are totally facing away from the light. These values can be used very nicely at lighting, you just specify the maximum brightness of your poly. If this is multiplied by the angle, it gives you the intensity of light that you actually see if the light doesn't hit it perpendicularly. There's also the possibility of ambient light, which is scattered light that reaches even in shadows. You just add a certain value to the light intensity, so that you never get total darkness.

Intensity = Ambient + Max_obj_color*(Normal • Light)

I guess this is about it... Now you can draw flat shaded objects, which is a pretty good start. At least I hope you can. I might have left out some things and confused you with others, so if something's not clear, just
ask me. I'm sure there's still lotsa work to be done on this tutorial.

Source Code and Programs

  • Source and Executable containing all the stuff from this tutorial. It's pretty well commented and explains the subtleties of programming specific parts of the system.
  • SVGA.C - my "graphics library", needed to compile the example source code. Get svga.h if you don't want errors.

    Related Links

    Go to my main programming page to find a few hundred of 'em :-)
    Siggraph docs on many computer graphics topics.
    Matrix Doc in the Game Programmer's Encyclopedia.

    Other Tutorials

    Shading and Lighting Methods - This tutorial explains how to make objects appear smooth. Gouraud and Phong shading is demonstrated on objects created by mathematical functions (pretty cool :-).
    Texture Mapping - Explains affine texture mapping (and environment mapping). Ideas for optimization are also presented.
    Advanced Engine Techniques - workin on it...




    This page was last updated on 11/04/97