3D wireframe cube with MicroPython
Basic 3D model rotation and projection

An ESP2866 is never going to compete with an actual graphics card. It certainly won't produce anything approaching modern games. But it still makes a nice platform to explore the basics of 3D graphics. In this short tutorial we'll go through the basics of creating a 3D scene and displaying it on an OLED screen using MicroPython.

This kind of mono wireframe 3D reminds me of early ZX Spectrum 3D games which mostly involved shooting one wobbly line at another, and looking at the resulting wobbly lines. It was awesome.

The 3D code here is based on this example for Pygame with some simplifications and the display code modified for working with framebuf.

Setting up

The display used here is a 128x64 OLED which communicates over I2C. We're using the ssd1306 module for OLED displays available in the MicroPython repository to handle this communication for us, and provide a framebuf drawing interface.

Upload the ssd1306.py file to your device's filesystem using the ampy tool (or the WebREPL).

ampy --port /dev/tty.wchusbserial141120 put ssd1306.py

With the ssd1306.py file on your Wemos D1, you should be able to import it as any other Python module. Connect to your device, and then in the REPL enter:

from machine import I2C, Pin
import ssd1306

If the import ssd1306 succeeds, the package is correctly uploaded and you're good to go.

Wire up the OLED display, connecting pins D1 to SCL and D2 to SDA.Provide power from G and 5V.

I2C OLED display wired to Wemos D1

To work with the display, we need to create an I2C object, connecting via pins D1 and D2 — hardware pin 4 & 5 respectively. Passing the resulting i2c object into our SSD1306_I2C class, along with screen dimensions, gets us our interface to draw with.

from machine import I2C, Pin
import ssd1306
import math


i2c = I2C(scl=Pin(5), sda=Pin(4))
display = ssd1306.SSD1306_I2C(128, 64, i2c)

Modelling 3D objects

The simplest way to model objects in 3D space is to store and manipulate their vertices only — for a cube, that means the 8 corners.

To rotate the cube we manipulate these points in 3 dimensional space. To draw the cube, we project these points onto a 2-dimensional plane, to give a set of x,y coordinates, and connect the vertices with our edge lines.

Rotation along each axis and the projection onto a 2D plane is described below.

The full code is available for download here if you want to skip ahead and start experimenting.

3D Rotation

Rotating an object in 3 dimensions is no different than rotating a object on a 2D surface, it's just a matter of perspective.

Take a square drawn on a flat piece of paper, and rotate it 90°. If you look before and after rotation the X and Y coordinates of any given corner change, but the square is still flat on the paper. This is analogous to rotating any 3D object along it's Z axis — the axis that is coming out of the middle of the object and straight up.

The same applies to rotation along any axis — the coordinates in the axis of rotation remain unchanged, while coordinates along other axes are modified.

# Rotation along X
y' = y*cos(a) - z*sin(a)
z' = y*sin(a) + z*cos(a)
x' = x


# Rotation along Y
z' = z*cos(a) - x*sin(a)
x' = z*sin(a) + x*cos(a)
y' = y

# Rotation along Z
x' = x*cos(a) - y*sin(a)
y' = x*sin(a) + y*cos(a)
z' = z

The equivalent Python code for the rotation along the X axis is shown below. It maps directly to the math already described. Note that when rotating in the X dimension, the x coordinates are returned unchanged and we also need to convert from degrees to radians (we could of course write this function to accept radians instead).

def rotateX(self, x, y, z, deg):
    """ Rotates this point around the X axis the given number of degrees. Return the x, y, z coordinates of the result"""
    rad = deg * math.pi / 180
    cosa = math.cos(rad)
    sina = math.sin(rad)
    y = y * cosa - z * sina
    z = y * sina + z * cosa
    return x, y, z

Projection

Since we're displaying our 3D objects on a 2D surface we need to be able to convert, or project, the 3D coordinates onto 2D. The approach we are using here is perspective projection.

If you imagine an object moving away from you, it gradually shrinks in size until it disappears into the distance. If it is directly in front of you, the edges of the object will gradually move towards the middle as it recedes. Similarly, a large square transparent object will have the rear edges appear 'within' the bounds of the front edges. This is perspective.

To recreate this in our 2D projection, we need to move points towards the middle of our screen the further away from our 'viewer' they are. Our x & y coordinates are zero'd around the center of the screen (an x < 0 means to the left of the center point), so dividing x & y coordinates by some amount of Z will move them towards the middle, appearing 'further away'.

The specific formula we're using is shown below. We take into account the field of view — how much of an area the viewer can see — the viewer distance and the screen height and width to project onto our framebuf.

x' = x * fov / (z + viewer_distance) + screen_width / 2
y' = -y * fov / (z + viewer_distance) + screen_height / 2

Point3D code

The complete code for a single Point3D is shown below, containing the methods for rotation in all 3 axes, and for projection onto a 2D plane. Each of these methods return a new Point3D object, allow us to chain multiple transformations and avoid altering the original points we define.

class Point3D:
    def __init__(self, x = 0, y = 0, z = 0):
        self.x, self.y, self.z = x, y, z

    def rotateX(self, angle):
        """ Rotates this point around the X axis the given number of degrees. """
        rad = angle * math.pi / 180
        cosa = math.cos(rad)
        sina = math.sin(rad)
        y = self.y * cosa - self.z * sina
        z = self.y * sina + self.z * cosa
        return Point3D(self.x, y, z)

    def rotateY(self, angle):
        """ Rotates this point around the Y axis the given number of degrees. """
        rad = angle * math.pi / 180
        cosa = math.cos(rad)
        sina = math.sin(rad)
        z = self.z * cosa - self.x * sina
        x = self.z * sina + self.x * cosa
        return Point3D(x, self.y, z)

    def rotateZ(self, angle):
        """ Rotates this point around the Z axis the given number of degrees. """
        rad = angle * math.pi / 180
        cosa = math.cos(rad)
        sina = math.sin(rad)
        x = self.x * cosa - self.y * sina
        y = self.x * sina + self.y * cosa
        return Point3D(x, y, self.z)

    def project(self, win_width, win_height, fov, viewer_distance):
        """ Transforms this 3D point to 2D using a perspective projection. """
        factor = fov / (viewer_distance + self.z)
        x = self.x * factor + win_width / 2
        y = -self.y * factor + win_height / 2
        return Point3D(x, y, self.z)

3D Simulation

We can now create a scene by arranging Point3D objects in 3-dimensional space. To create a cube, rather than 8 discrete points, we will connect our vertices to their adjacent vertices after projecting them onto our 2D surface.

Vertices

The vertices for a cube are shown below. Our cube is centered around 0 in all 3 axes, and rotates around this centre.

self.vertices = [
    Point3D(-1,1,-1),
    Point3D(1,1,-1),
    Point3D(1,-1,-1),
    Point3D(-1,-1,-1),
    Point3D(-1,1,1),
    Point3D(1,1,1),
    Point3D(1,-1,1),
    Point3D(-1,-1,1)
]

Polygons or Lines

As we're drawing a wireframe cube, we actually have a couple of options — polygons or lines.

The cube has 6 faces, which means 6 polygons. To draw a single polygon requires 4 lines, making a total draw for the wireframe cube with polygons of 24 lines. We draw more lines than needed, because each polygon shares sides with 4 others.

In contrast drawing only the lines that are required, a wireframe of the cube can be drawn using only 12 lines — half as many.

For a filled cube, polygons would make sense, but here we're going to use the lines only, which we call edges. This is an array of indices into our vertices list.

self.edges  = [
    # Back
    (0, 1),
    (1, 2),
    (2, 3),
    (3, 0),
    # Front
    (5, 4),
    (4, 7),
    (7, 6),
    (6, 5),
    # Front-to-back
    (0, 5),
    (1, 4),
    (2, 7),
    (3, 6),
]

On each iteration we apply the rotational transformations to each point, then project it onto our 2D surface.

r = v.rotateX(angleX).rotateY(angleY).rotateZ(angleZ)

# Transform the point from 3D to 2D
p = r.project(*self.projection)

# Put the point in the list of transformed vertices
t.append(p)

Then we iterate our list of edges, and retrieve the relevant transformed vertices from our list t. A line is then drawn between the x, y coordinates of two points making up the edge.

for e in self.edges:
    display.line(*to_int(t[e[0]].x, t[e[0]].y, t[e[1]].x, t[e[1]].y, 1))

The to_int is just a simple helper function to convert lists of float into lists of int to make updating the OLED display simpler (you can't draw half a pixel).

def to_int(*args):
    return [int(v) for v in args]

The complete simulation code is given below.

class Simulation:
    def __init__(self, width=128, height=64, fov=64, distance=4, rotateX=5, rotateY=5, rotateZ=5):

        self.vertices = [
            Point3D(-1, 1,-1),
            Point3D( 1, 1,-1),
            Point3D( 1,-1,-1),
            Point3D(-1,-1,-1),
            Point3D(-1, 1, 1),
            Point3D( 1, 1, 1),
            Point3D( 1,-1, 1),
            Point3D(-1,-1, 1)
        ]

        # Define the edges, the numbers are indices to the vertices above.
        self.edges  = [
            # Back
            (0, 1), (1, 2), (2, 3), (3, 0),
            # Front
            (5, 4), (4, 7), (7, 6), (6, 5),
            # Front-to-back
            (0, 4), (1, 5), (2, 6), (3, 7),
        ]

        # Dimensions
        self.projection = [width, height, fov, distance]

        # Rotational speeds
        self.rotateX = rotateX
        self.rotateY = rotateY
        self.rotateZ = rotateZ

    def run(self):
        # Starting angle (unrotated in any dimension)
        angleX, angleY, angleZ = 0, 0, 0

        while 1:
            # It will hold transformed vertices.
            t = []

            for v in self.vertices:
                # Rotate the point around X axis, then around Y axis, and finally around Z axis.
                r = v.rotateX(angleX).rotateY(angleY).rotateZ(angleZ)

                # Transform the point from 3D to 2D
                p = r.project(*self.projection)

                # Put the point in the list of transformed vertices
                t.append(p)

            display.fill(0)

            for e in self.edges:
                display.line(*to_int(t[e[0]].x, t[e[0]].y, t[e[1]].x, t[e[1]].y, 1))

            display.show()

            # Continue the rotation
            angleX += self.rotateX
            angleY += self.rotateY
            angleZ += self.rotateZ

Running a simulation

To display our cube we need to create a Simulation object, and then call .run() to start it running.

s = Simulation()
s.run()

Simulation with default parameters

You can pass in different values for rotateX, rotateY, rotateZ to alter the speed of rotation. Set a negative value to rotate in reverse.

s = Simulation()
s.run()

The fov and distance parameters are set at sensible values for the 128x64 OLED by default (based on testing). So you don't need to change these, but you can.

s = Simulation(fov=32, distance=8)
s.run()

Simulation with default parameters

The width and height are defined by the display, so you won't want to change these unless you're using a different display output.

Continue reading

Displaying images on OLED screens  electronics

We've previously covered the basics of driving OLED I2C displays from MicroPython, including simple graphics commands and text. Here we look at displaying monochrome 1 bit-per-pixel images and animations using MicroPython on a Wemos D1. Processing the images and correct choice of image-formats is important to get the most ... More

Discussion