An ESP2866 is never going to compete with an actual graphics card. But it has more than enough oomph to explore the fundamentals 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
|Wemos D1 v2.2+ or good imitations.||amazon|
|0.96in OLED Screen 128x64 pixels, I2c interface.||amazon|
|Breadboard Any size will do.||amazon|
|Wires Loose ends, or jumper leads.|
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.
ssd1306.py file to your device's filesystem using the ampy tool (or the WebREPL).
ampy --port /dev/tty.wchusbserial141120 put ssd1306.py
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
import ssd1306 succeeds, the package is correctly uploaded and you're good to go.
Wire up the OLED display, connecting pins
SDA.Provide power from
To work with the display, we need to create an
I2C object, connecting via pins
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.
tip: The full code is available for download here if you want to skip ahead and start experimenting.
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
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
x' = x * fov / (z + viewer_distance) + screen_width / 2 y' = -y * fov / (z + viewer_distance) + screen_height / 2
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)
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.
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].x, t[e].y, t[e].x, t[e].y, 1))
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: 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].x, t[e].y, t[e].x, t[e].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()
You can pass in different values for
rotateZ to alter the speed of rotation. Set a negative value to rotate in reverse.
s = Simulation() s.run()
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()
height are defined by the display, so you won't want to change these unless you're using a different display output.