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:
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:
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.