Sol's Graphics Tutorial

10 - Simple 3d Transformations

3d graphics is something I'll largely avoid in this tutorial, but it's inevitable, so let's dabble a bit.

Take a copy of the previous chapter, resulting in ch10. Make sure you still have drawcircle() - if not, grab it from the primitives.

First we'll need to define a vertex. Vertex is just a point in space. It has x, y and z coordinates. Copy-paste the following near the top of the source file (before the global variables):

// Vertex structure
struct Vertex
{
  float x, y, z;
};

Next, we'll need to define some vertices. Add the following two tables after the vertex structure definition:

// Original vertices
Vertex *gVtx;
// Transformed vertices
Vertex *gRVtx;

We'll need space for both original and transformed coordinates. I'll explain this in a bit.

Here's a new init():

void init()
{
  gVtx = new Vertex[100];
  gRVtx = new Vertex[100];

  for (int i = 0; i < 100; i++)
  {
    gVtx[i].x = (rand() % 32768) - 16384.0f;
    gVtx[i].y = (rand() % 32768) - 16384.0f;
    gVtx[i].z = (rand() % 32768) - 16384.0f;
    float len = (float)sqrt(gVtx[i].x * gVtx[i].x + 
                            gVtx[i].y * gVtx[i].y + 
                            gVtx[i].z * gVtx[i].z);
    if (len != 0)
    {
      gVtx[i].x /= len;
      gVtx[i].y /= len;
      gVtx[i].z /= len;
    }
  }
}

We allocate the arrays for 100 vertices. For each vertex, we first assign the x, y and z variables random values from -16384 to 16384. Then, using three-dimensional version of the pythagoras' theorem, we calculate the distance from origin (0,0,0) to our random point.

Then, unless the length was zero (which is rather unlikely), we divide the coordinates by the length. After this we have 100 random positions on the surface of a sphere with a radius of 1.

Let's render the vertices so we can see what's happening. Replace render() with:

void render(Uint64 aTicks)
{
  for (int i = 0; i < WINDOW_WIDTH * WINDOW_HEIGHT; i++)
    gFrameBuffer[i] = 0xff000000;

  drawcircle(WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2, WINDOW_WIDTH / 4, 0xff3f3f3f);

  memcpy(gRVtx, gVtx, sizeof(Vertex) * 100);

  for (int i = 0; i < 100; i++)
  {
    int c = 0xffffffff;
    if (gRVtx[i].z < 0)
      c = 0xff7f7f7f;

    drawcircle((int)(gRVtx[i].x * (WINDOW_WIDTH / 4) + WINDOW_WIDTH / 2),
               (int)(gRVtx[i].y * (WINDOW_WIDTH / 4) + WINDOW_HEIGHT / 2),
               2, c);
  }
}

Here we clear the screen with a memset(), and then draw a dark grey circle in the center with a radius of WINDOW_WIDTH / 4. This circle will be our "ball".

Then we loop through the vertices and draw them on top of our circle. Since the vertices' coordinates will always be in the range of [-1..1], multiplying the coordinate values with our ball's radius will always render the vertices inside the ball. The vertices will be rendered in white, unless their Z coordinate value is below zero, in which case the color is mid-grey.

Compile and run.

The choice of colors we've made for the vertices and the ball result in an illusion of translucency, even though there's no blending involved.

Now that we have the basic set up done, let's go into transformations. Staying in 2d as long as possible, we'll start with z rotation. In our case, Z axis is considered to be the depth, so rotating around the z axis means 2d rotation around the origin.

Paste this above render():

void rotate_z(double angle)
{
  float ca = (float)cos(angle);
  float sa = (float)sin(angle);
  for (int i = 0; i < 100; i++)
  {
    float x = gRVtx[i].x * ca - gRVtx[i].y * sa;
    float y = gRVtx[i].x * sa + gRVtx[i].y * ca;
    gRVtx[i].x = x;
    gRVtx[i].y = y;
  }
}

Here we first calculate the sine and cosine values for the desired angle. For each vertex, we calculate x1 = x0 * cos(a) - y0 * sin(a) and y1 = x0 * sin(a) + y0 * cos(a).

Consider a point in (1,0). The formulas simplify to x1 = 1 * cos(a) - 0 and y1 = 1 * sin(a) + 0.

If you draw something in x = cos(a) * radius + middlePointX, y = sin(a) * radius + middlePointY, the object will rotate around the middle point at 'radius' distance. (You probably have already seen this in practice if you've followed this tutorial).

Finally, paste the following after the memcpy() in render():

rotate_z(aTicks * 0.0005);

Compile and run. The vertices should now be rotating around the center.

Now then, we have several reasons to have the original versus rotated data. This way we can control the exact orientation of the object relative to the original orientation. Consider if we wanted to render 50 copies of the same 3d object in different orientations. Accuracy of the floating point math is also an issue: If we were to constantly overwrite our rotated data, rounding errors would eventually creep in.

Rotating around the Y and X axes works pretty much the same way as rotating around the Z axis. Paste these functions after the rotate_z() one:

void rotate_y(double angle)
{
  float ca = (float)cos(angle);
  float sa = (float)sin(angle);
  for (int i = 0; i < 100; i++)
  {
    float z = gRVtx[i].z * ca - gRVtx[i].x * sa;
    float x = gRVtx[i].z * sa + gRVtx[i].x * ca;
    gRVtx[i].z = z;
    gRVtx[i].x = x;
  }
}

void rotate_x(double angle)
{
  float ca = (float)cos(angle);
  float sa = (float)sin(angle);
  for (int i = 0; i < 100; i++)
  {
    float y = gRVtx[i].y * ca - gRVtx[i].z * sa;
    float z = gRVtx[i].y * sa + gRVtx[i].z * ca;
    gRVtx[i].y = y;
    gRVtx[i].z = z;
  }
}

As you can see, the functions are very similar. Consider looking at the coordinate system from the direction of the Y or X axis.

Next, find and replace the rotate_z() call in render() with the following:

rotate_x(aTicks * 0.001);
rotate_y(aTicks * 0.00025);
rotate_z(aTicks * 0.0005);

Compile and run again. Play with the values, remove some rotations, change the order of the rotations.

Once you're done playing, replace the rotations with these:

double rotz = aTicks * 0.0005;
double roty = aTicks * 0.002;

rotate_y(roty);
rotate_z(rotz);

When run, you'll see that the ball is rotating quickly around its Y axis and slowly around the Z axis. To mark the point towards which we're rolling, add the following lines after the loop in which we're rendering the vertices (at the end of render()):

drawcircle((int)((WINDOW_WIDTH / 3) * cos(rotz) + WINDOW_WIDTH / 2),
           (int)((WINDOW_WIDTH / 3) * sin(rotz) + WINDOW_HEIGHT / 2), 6, 0xffffffff);

Compile and run again. Now, what happens if you swap the order of the rotations? Can you figure out why?

That's all I'm going to cover this time. Note that we're not doing any perspective projection!

Additional things to try:

  • Adjust the number of vertices.
  • Create different kinds of patterns with the vertices. For instance, try setting x to sin(i*PI/50), y to cos(i*PI/50) and z to 0. Another example is sin(i*PI/20), cos(i*PI/20), (i-50)/50.0f;
  • Can you optimize the rotations by combining them?
  • Adjust the vertex sizes depending on the z values.
  • Since growing the circles will make our lack of sorting obvious, sort the vertices by their z values and render in the sorted order.
  • It may be enough to render z<0 circles first, followed by z>=0.
  • Add perspective projection.

Next up: 11 - Kefrens Bars..

Any comments etc. can be emailed to me.