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

0.96in OLED Screen 128x64 pixels, I2c interface. | amazon | ||

Breadboard Any size will do. | amazon | ||

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)
```

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