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 framebuf
.
Requirements
- Wemos D1 v2.2+ or good imitations.
- 0.96in OLED Screen 128x64 pixels, I2c interface.
- Breadboard Any size will do.
- Wires (Loose ends, or jumper leads.)
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
.
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)
To support developers in [[ countryRegion ]] I give a [[ localizedDiscount[couponCode] ]]% discount on all books and courses.
[[ activeDiscount.description ]] I'm giving a [[ activeDiscount.discount ]]% discount on all books and courses.
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:
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()
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()
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.