How I Built A Simple (Horrible) 3D Graphics Engine in python matplotlib - Part 4: Speen!

published-date: 17 Feb 2023 17:16 +0700
categories: dumber-day-by-day python-hijinks
tags: python matplotlib

Posts in This Series


speen

Before we begin, there’s a reason why vertex buffer needed to be turned into as np.array. Numpy is packed with various math magic only wizards can do. At least to my knowledge.

By casting our python builtin list into numpy array, we basically transformed our vertices vector buffer list into matrix, on which we can do various stuff with it.

Numpy Implementation

One thing I want to point out, writing min and max for the limit argument for 3D plt constructor is a little bit finicky. The resulting graph will always stretch or de-stretch our cube since all the limit follows each axis’s extrema. This limit should’ve been assigned as a constant for a known maximum distance of your cube vertex. Or, with a little bit of trickery we can just make a function that dynamically calculate the extrema for us.

def get_extrema(vertices, origin=(0,0,0)):
  # finds min and max of each axis, which represents two adjacent outermost coordinate
  x_max, y_max, z_max, _ = (vertices - np.array([*origin, 1])).max(axis=0)
  x_min, y_min, z_min, _ = (vertices - np.array([*origin, 1])).min(axis=0)

  # calculate the distance 
  d = np.sqrt(((x_max - x_min)**2 + (y_max - y_min)**2 + ((z_max - z_min)**2)))
  r = d/2 
  
  # extrema => origin with offset r
  return ((origin[0] - r, origin[0] + r), (origin[1] - r, origin[1] + r), (origin[2] - r, origin[2] + r))

Transformation Matrixes

This implementation of transformation matrixes is different compared to the Colab code I shared in the first post on the series. But in the overall idea is still the same throughout.

For further read on linear transformation, you can refer to this Wikipedia article. In this post not all matrixes are implemented. But in practice you can build any transformation matrix constructor that fits your need. Here are few examples.

Translation

def mat_translate(values=(0,0,0)):
  translation_matrix = \
  np.array([
      [1,         0,         0,         0],
      [0,         1,         0,         0],
      [0,         0,         1,         0],
      [values[0], values[1], values[2], 1],
  ])    
  return translation_matrix

Rotation

def mat_rotate(values=(0,0,0)):

  cx, sx = np.cos(values[0]), np.sin(values[0])
  cy, sy = np.cos(values[1]), np.sin(values[1])
  cz, sz = np.cos(values[2]), np.sin(values[2])
  
  rz = np.array([
      [cz,  -sz, 0,   0],
      [sz,  cz,  0,   0],
      [0,   0,   1,   0],
      [0,   0,   0,   1],
  ])    

  ry = np.array([
      [cy,  0,    sy,  0],
      [0,   1,    0,   0],
      [-sy, 0,    cy,  0],
      [0,   0,    0,   1],
  ])    

  rx = np.array([
      [1,   0,    0,   0],
      [0,   cx,   -sx, 0],
      [0,   sx,   cx,  0],
      [0,   0,    0,   1],
  ])  
  
  rotation_matrix = np.linalg.multi_dot([rz, ry, rx])
  return rotation_matrix

Scale

def mat_scale(values=(1,1,1)):
  scale_matrix = \
  np.array([
      [values[0], 0, 0, 0],
      [0, values[1], 0, 0],
      [0, 0, values[2], 0],
      [0, 0, 0,         1],
  ])    
  return scale_matrix

Applying transformation

Using previous post’s vertices, We’ll re-plot the cube with our extrema function which will result in seemingly smaller cube.

Figure 1

Now constructing transformation steps can be done as follows.

# for the sake of continuity, extremas refer to verts before transformation.
plot_limit = get_extrema(vertices)

# generate transformation matrix
m_t = mat_translate((2,0,0)) # translates 2 unit in x axis
m_r = mat_rotation((2,0,0))  # rotates 2 radian ccw in x axis
m_s = mat_scale((1,.5,1))    # scales by .5 in y axis

# transform vertices. in this demo I'll use seperate variable to contain transformed verts.
transformed_vertices = np.dot(vertices, m_t)
transformed_vertices = np.dot(transformed_vertices, m_r)
transformed_vertices = np.dot(transformed_vertices, m_s)

# >> which is equivalent to:
transformed_vertices = np.linalg.multi_dot([vertices, m_t, m_r, m_s])

# >> or even better, create reusable desired transformation into one transformation matrix
m_A = np.linalg.multi_dot([m_t, m_r, m_s])
transformed_vertices = np.dot(vertices, m_A)


# plot resulting verts
x_values = transformed_vertices[:,0]
y_values = transformed_vertices[:,1]
z_values = transformed_vertices[:,2] 

fig, ax = ConstructSubplot3D(limit=plot_limit) 
ax.plot3D(x_values, y_values, z_values)
fig.show()

Which in turn will result in this following graph.

Figure 3

Goofy ahh lookin cube.

Footnote

Transformation dot product ordering does matter! Take a look on snippet below.

# oh vey
plot_limit = get_extrema(vertices)

# transformation matrix, same as above
m_t = mat_translate((2,0,0))
m_r = mat_rotation((2,0,0))
m_s = mat_scale((1,.5,1))

# Now first matrix is just a copy of previous multi_dot
m_A_1 = np.linalg.multi_dot([m_t, m_r, m_s])

# On the second matrix, I switched the order of rotation and scaling matrix
m_A_2 = np.linalg.multi_dot([m_t, m_s, m_r])

# .dot.dot.dot.dot.dot.dot.dot.dot.dot.dot.dot.dot.dot.dot...
transformed_vertices_1 = np.dot(vertices, m_A_1)
transformed_vertices_2 = np.dot(vertices, m_A_2)

# plot resulting verts. I'll just one-line'd everything at this point. sorry.
x_values_1, y_values_1, z_values_1 = transformed_vertices_1[:,0], transformed_vertices_1[:,1], transformed_vertices_1[:,2] 
x_values_2, y_values_2, z_values_2 = transformed_vertices_2[:,0], transformed_vertices_2[:,1], transformed_vertices_2[:,2] 

fig, ax = ConstructSubplot3D(limit=plot_limit, y=2, figsize=(10,5)) 
ax[0].plot3D(x_values_1, y_values_1, z_values_1)
ax[1].plot3D(x_values_2, y_values_2, z_values_2)
fig.show()

Which in turn will result in this following graph. yes i just copy-pasted this line

Figure 4

The figure on the left is our og transformation, where scaling is applied after the cube is rotated. While right figure shows if scale is applied before being rotated.

And.. That’s a Wrap!

I’ll add another post where I’ll discuss turning these graph into gif using ffmpeg, limitations of this basically useless write-up, and another google.colab notebook you can play around with.