Building a 3D Graphics Pipeline on an Arduino, Part 4

We have all the pieces. Let’s put them together, shall we?

With the last article we had a clipping algorithm which could clip lines so that only the visible lines are displayed. Now let’s put the whole thing together.


Some Math in Code.

On our second article we introduced the concept of Homogeneous Coordinates. This is how we manipulate points in 3D, and you see the same matrix math used everywhere in 3D graphics, including OpenGL.

First, let’s create the methods for manipulating our matrices and vectors.

First, we need a 3D matrix, which we define as:

class G3DMatrix
{
    public:
        float           a[4][4];
};

Recall our 3D matrices are 4×4 matrices:

Matrix Stuff

Our convention is that items in our matrix across is the first index, and down is the second.

Since we’re using C++, we can also pack our matrix full of all those declarations which make our life easier. Specifically we can add routines for initializing our matrix as a translation matrix, a rotation matrix, or a scale matrix, as well as our perspective matrix. We can also add our multiply method as well, just to keep things tidy.

class G3DMatrix
{
    public:
                        G3DMatrix();

        // Initialize matrix
        void            setIdentity();
        void            setTranslate(float x, float y, float z);
        void            setScale(float x, float y, float z);
        void            setScale(float x);
        void            setRotate(uint8_t axis, float angle);
        void            setPerspective(float fov, float near);

        // Inline multiply transformation matrix
        void            multiply(const G3DMatrix &m);
        
        // Raw contents of the matrix
        float           a[4][4];
};

We also need our 3D vector as a homogeneous coordinate, and we include our vector/matrix multiply routines as well.

struct G3DVector {
    float x;
    float y;
    float z;
    float w;

    // Math support
    void                multiply(const G3DMatrix &m, const G3DVector &v);
};

Everything we’re writing here should seem familiar if you made it to the end of my article on homogeneous coordinates. Basically, the idea is that a 3D point (x,y,z) becomes our 3D homogeneous vector (x,y,z,1), and any 3D homogeneous coordinate (x,y,z,w) becomes (x/w, y/w, z/w).

The implementation of each of the matrix initialization methods follows pretty quickly from our homogeneous matrices in the prior article. For example, our implementation of setTranslate which creates a translation matrix:

Translation Matrix

looks like:

void G3DMatrix::setTranslate(float x, float y, float z)
{
    setIdentity();
    a[0][3] = x;
    a[1][3] = y;
    a[2][3] = z;
}

Our Transformations

To our G3D class, we create methods for transformations, scaling, and the transformation matrix used to translate points in our 3D coordinate space.

class G3D
{
    public:
                G3D(Adafruit_GFX &lib);
                ~G3D();

        void    setColor(int16_t c)
                    {
                        color = c;
                    }
        
        void    begin();
        void    end();
        void    move(float x, float y, float z)
                    {
                        p4movedraw(false,x,y,z);
                    }
        void    draw(float x, float y, float z)
                    {
                        p4movedraw(true,x,y,z);
                    }
        void    point(float x, float y, float z)
                    {
                        p4point(x,y,z);
                    }

        void    translate(float x, float y, float z);
        void    scale(float x, float y, float z);
        void    scale(float s);
        void    rotate(uint8_t axis, float angle);
        void    perspective(float fov, float nclip);
        void    orthographic(void);

        G3DMatrix transformation;
    private:

We expose all of our transformation stuff here to simplify our work, by keeping the pieces all in one place. For example, our translate method looks like:

void G3D::translate(float x, float y, float z)
{
    G3DMatrix m;
    m.setTranslate(x,y,z);
    transformation.multiply(m);
}

This will post-multiply our matrix into our current transformation matrix. We’ll talk about the ramification of this below.

Finally we need our p4movedraw and p4point methods for our class:

void G3D::p4movedraw(bool drawFlag, float x, float y, float z)
{
    G3DVector t;

    t.x = transformation.a[0][0] * x + transformation.a[0][1] * y + transformation.a[0][2] * z + transformation.a[0][3];
    t.y = transformation.a[1][0] * x + transformation.a[1][1] * y + transformation.a[1][2] * z + transformation.a[1][3];
    t.z = transformation.a[2][0] * x + transformation.a[2][1] * y + transformation.a[2][2] * z + transformation.a[2][3];
    t.w = transformation.a[3][0] * x + transformation.a[3][1] * y + transformation.a[3][2] * z + transformation.a[3][3];

    p3movedraw(drawFlag,t);
}

void G3D::p4point(float x, float y, float z)
{
    G3DVector t;

    t.x = transformation.a[0][0] * x + transformation.a[0][1] * y + transformation.a[0][2] * z + transformation.a[0][3];
    t.y = transformation.a[1][0] * x + transformation.a[1][1] * y + transformation.a[1][2] * z + transformation.a[1][3];
    t.z = transformation.a[2][0] * x + transformation.a[2][1] * y + transformation.a[2][2] * z + transformation.a[2][3];
    t.w = transformation.a[3][0] * x + transformation.a[3][1] * y + transformation.a[3][2] * z + transformation.a[3][3];

    p3point(t);
}

This simply does our matrix/vector multiplication, then passes the resulting point into our 3D clipping engine.


Testing the whole thing.

Our test code is very simple.

First, we need to set up our drawing system. We first need to set up our drawing engine, and then pass it as a construction parameter to our 3D drawing engine:

// For the Adafruit shield, these are the default.
#define TFT_DC 9
#define TFT_CS 10

// Use hardware SPI (on Uno, #13, #12, #11) and the above for CS/DC
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC);

// Graphics setup
G3D draw(tft);

And finally we set up a couple of global variables for the rotation angle.

// Rotation
static float GXAngle;
static float GYAngle;

Our setup code simply erases the screen to black and resets our globals.

void setup() 
{
    tft.begin();
    tft.fillScreen(ILI9341_BLACK);
    
    GXAngle = 0;
    GYAngle = 0;
}

And our loop draws our box at the origin, after setting up the transformation matrix. We wait a little bit, then we redraw the box in black; this is faster than erasing the screen to black. We rotate our box, and try again.

void loop() 
{
    draw.begin();
    draw.setColor(ILI9341_RED);
    transform();
    drawBox(0,0,0); 
    draw.end();

    delay(100);

    draw.begin();
    draw.setColor(ILI9341_BLACK);
    drawBox(0,0,0); 
    draw.end();

    GXAngle += 0.01;
    GYAngle += 0.02;
}

Our transformation matrix.

The code we use to set up our transformation matrix is:

void transform()
{
    draw.transformation.setIdentity();
    draw.perspective(1.0f,0.5f);
    draw.translate(0,0,-6);
    draw.rotate(AXIS_X,GXAngle);
    draw.rotate(AXIS_Y,GYAngle);
}

We initialize the matrix in the opposite direction in which we use the transformations. In other words, our transformation matrix first rotates our cube around the Y axis. Then it rotates around the X axis. We then move the object 6 units away from our view, and finally set up the perspective matrix.

The order of this is important, because it means we could animate multiple objects by saving and restoring the matrix. For example, we could represent two cubes rotating around each other independently by partially setting up our transformation matrix, saving the intermediate result, and then setting up the specific transformations for the two separate cubes.

void transform()
{
    draw.transformation.setIdentity();
    draw.perspective(1.0f,0.5f);
    draw.translate(0,0,-6);

    G3DMatrix save = draw.transformation;
    draw.rotate(AXIS_X,GXAngle);
    drawFirstCube();

    draw.transformation = save;
    draw.rotate(AXIS_Y,GYAngle);
    drawSecondCube();
}

Put it all together and we get a rotating cube on our display:

The complete sources are at GitHub.

Published by

William Woody

I'm a software developer who has been writing code for over 30 years in everything from mobile to embedded to client/server. Now I tinker with stuff and occasionally help out someone with their startup.

Leave a Reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s