# 3D Graphics Engines, Python3

## 2018-05-07

back to projects

### Baskerville Scene

It occurs to me many might think 3D graphics beyond the reach of beginners, and perhaps that is so. Here I present what is probably the simplest 3D graphics engine, if you call it that. This renders the Utah Teapot in silloutte without perspective.

## Teapot Sillouette ```# silouette.py
#  by Hannah Leitheiser
#  2018-05-06
# Read a simple .obj file and generate a sillouette of the object inside.
#
# requires python3 and PIL

from PIL import Image, ImageDraw
from math import *

# form: vertexes = [ (x, y, z), (x, y, z), ... ]
#       triangles = [ ( (x1, y1, z1), (x2, y2, z2), (x3, y3, z3) ), # first triangle
#      		      ( (x1, y1, z1), (x2, y2, z2), (x3, y3, z3) ), # second triangle
#			... ]

vertexes = []
triangles = []

# Mostly found by experiment.

xoffset = -4
yoffset = -3.5
scale = 100

# Read the .obj file.

# This won't work for complex .obj files, but the one we are going to read has only
# two kinds of line.  The v lines indicates verexes or 3-space points.
# The f for faces indicate triangles, specifying the vertex index, aparently
# starting at one.

# ----------------------- excerpt from teapot.obj ----------------------------------
#	v -3.000000 1.800000 0.000000
#	v -2.991600 1.800000 -0.081000
#	v -2.991600 1.800000 0.081000
#	v -2.989450 1.666162 0.000000
#
#	(...)
#
#	f 688 676 694
#	f 742 748 712
#	f 712 694 742
#	f 794 800 748

teapotFile = open("teapot.obj")
for line in teapotFile.readlines():
if line == 'v':
number = line[2:].split()
vertexes.append( ( float(number),
-float(number),
float(number)))
if line == 'f':
number = line[2:].split()
# Vertexes are numbered from 1 in the file, but indexed from 0 in Python.
triangles.append( (
vertexes[int(number)-1],
vertexes[int(number)-1],
vertexes[int(number)-1] )
)
teapotFile.close()

# Now we have the 3-space coordinates of the three points of the triangles stored
# in the list triangles.

# Create a new white image.

im = Image.new('RGB', (800, 400), color=(255,255,255))
draw = ImageDraw.Draw(im)

# Here we are going to draw the triangles in the simplest way -- ignore the depth or z value.

for triangle in triangles:
draw.polygon([ ( (triangle-xoffset) * scale,
(triangle - yoffset) * scale ),
( (triangle-xoffset) *scale,
(triangle - yoffset) * scale ),
( (triangle-xoffset) * scale,
(triangle - yoffset) * scale),
( (triangle-xoffset) * scale,
(triangle - yoffset) * scale) ],
fill = 'black')

im.save('teapot_sillouette.png')
```

Link to teapot.obj

## Spinning Teapot

Now, for something slightly more interesting, with a bit of trigonometry you can rotate the teapot. ```# silouette_spin.py
#  by Hannah Leitheiser
#  2018-05-07
# Read a simple .obj file and animate the object rotating.

from PIL import Image, ImageDraw
from math import *

# form: vertexes = [ (x, y, z), (x, y, z), ... ]
#       triangles = [ ( (x1, y1, z1), (x2, y2, z2), (x3, y3, z3) ), # first triangle
#      		      ( (x1, y1, z1), (x2, y2, z2), (x3, y3, z3) ), # second triangle
#

vertexes = []
triangles = []

# Mostly found by experiment.

xoffset = -4
yoffset = -3.5
scale = 100

# ----------------------------- Read the .obj file -------------------------------

# Read the .obj file.

# This won't work for complex .obj files, but the one we are going to read has only
# two kinds of line.  The v lines indicates verexes or 3-space points.
# The f for faces indicate triangles, specifying the vertex index, aparently
# starting at one.

# ----------------------- excerpt from teapot.obj ----------------------------------
#	v -3.000000 1.800000 0.000000
#	v -2.991600 1.800000 -0.081000
#	v -2.991600 1.800000 0.081000
#	v -2.989450 1.666162 0.000000
#
#	(...)
#
#	f 688 676 694
#	f 742 748 712
#	f 712 694 742
#	f 794 800 748

teapot = open("teapot.obj")
for line in teapot.readlines():
if line == 'v':
number = line[2:].split()
vertexes.append( ( float(number),
-float(number),
float(number)))
if line == 'f':
number = line[2:].split()
# Vertexes are numbered from 1 in the file, but indexed from 0 in Python.
triangles.append( (
vertexes[int(number)-1],
vertexes[int(number)-1],
vertexes[int(number)-1] )
)
teapot.close()

# ------------------------------ animate ----------------------------------------

for frame in range(100):

# white background

im = Image.new('RGB', (800, 400), color=(255,255,255))
draw = ImageDraw.Draw(im)

for triangle in triangles:
draw.polygon([ ( (triangle-xoffset) * scale,
(triangle - yoffset) * scale ),
( (triangle-xoffset) *scale,
(triangle - yoffset) * scale ),
( (triangle-xoffset) * scale,
(triangle - yoffset) * scale),
( (triangle-xoffset) * scale,
(triangle - yoffset) * scale) ],
fill = 'black')
im.save('teapot{:03}.png'.format(frame))

# Rotate the object around the line x=z=0.

#  This is sorta the hairy part, if you're not handy with trig.  It helps me to
#  think about a unit circle.  A point moving around a circle with center
#  (0, 0) could be given by
#
#     x = cos(theta) * r; y = sin(theta) * r.
#
#  The Utah teapot .obj file creator has been kind enough to make the center of
#  the object 0.  r can be found with the pathogrean theorem.  So to turn
#  the object, you solve for theta, add a bit to theta, and recompute x and y.
#  Although note, here because we're not turning the pot with x and y, it ends
#  up we need to use x and z.

theta = 2*pi/100
for index in range(len(triangles)):
triangle = triangles[index]
newvertexes = []
for vertex in triangle:
r = (vertex ** 2 + vertex ** 2) ** 0.5
# guard against divie by zero.
if r > 0:
if(vertex > 0):
newtheta = acos(vertex/r) + theta
else:
newtheta = - acos(vertex/r) + theta
newvertexes.append(
( sin ( newtheta ) * d,
vertex,
cos ( newtheta ) * d) )
else:
newvertexes.append( (0,vertex,0 ) )
triangles[index] = tuple(newvertexes)
```

## Baskerville Scene

To be honest, this is just a bit of fun. I've introduced perspective, color, multiple objects, and basic z-sorting. It's probably enough to create a very rought game, if you could optimize it for speed, although that's not my goal right now. ```# scene.py
#  by Hannah Leitheiser
#  2018-05-07
# Create a scene.  I've just read The Hound of the Baskervilles so I'm imagining that Moor
#  and great hall.  With teapots strewn around.  Holmes will have to figure that one out,
#  I guess.

from PIL import Image, ImageDraw
from math import *
import random

# form: vertexes = [ (x, y, z), (x, y, z), ... ]
#       triangles = [ ( (x1, y1, z1), (x2, y2, z2), (x3, y3, z3),
#				(red, green, blue) ),   # first triangle
#      		      ( (x1, y1, z1), (x2, y2, z2), (x3, y3, z3) ),
#				(red, green, blue) ),   # second triangle
#			... ]

vertexes = []
triangles = []

# Center of the image, mostly.

xoffset = 400
yoffset = 250

# I think, although did not prove, that will make my image corespond roughtly
# to a 50 mm lens, 24 mm film when objects are expressed in meters.

focallength = 0.05
scale = 800*0.024

# ----------------------------- importObject() ------------------------------------------

# Read the .obj file.

# This won't work for complex .obj files, but the one we are going to read has only
# two kinds of line.  The v lines indicates verexes or 3-space points.
# The f for faces indicate triangles, specifying the vertex index, aparently
# starting at one.

# ----------------------- excerpt from teapot.obj ----------------------------------
#	v -3.000000 1.800000 0.000000
#	v -2.991600 1.800000 -0.081000
#	v -2.991600 1.800000 0.081000
#	v -2.989450 1.666162 0.000000
#
#	(...)
#
#	f 688 676 694
#	f 742 748 712
#	f 712 694 742
#	f 794 800 748

def importObject(filename, placementVector, color, scale=1):
start = len(vertexes)-1
objectFile = open(filename)
for line in objectFile.readlines():
if line == 'v':
number = line[2:].split()
vertexes.append( ( float(number)*scale+placementVector,
float(number)*scale + placementVector,
float(number)*scale + placementVector))
if line == 'f':
number = line[2:].split()
triangles.append( ( vertexes[int(number)+start],
vertexes[int(number)+start],
vertexes[int(number)+start], color ) )
objectFile.close()

# -------------------------- generate Scene ---------------------------------------------

# Add a house, and put a tree next to it.

importObject("house.obj", (0,-1.5,10), (69,39,10), 1)
importObject("tree.obj",  (-10,2,10),  (108,60,10),2)

# Function for elevation of terrain

def terrainFunction(x, y):
return -3*sin((x-25)/10) - (y/15)*cos(y**0.2)-2

for x in range(100):
for z in range(100):
# Add triangles for the ground.  We're going to color them based
# on their elevation for some contrast.
triangles.append( ( ( x-50, terrainFunction(x,z), z-50),
( (x+1)-50, terrainFunction(x+1,z) , z-50 ),
( (x)-50, terrainFunction(x,z+1), (z+1)-50 ),
(0, 128+int(10*terrainFunction(x+0.5,z+0.5)),10 )  ) )
triangles.append( ( ( x-50, terrainFunction(x,z+1), (z+1)-50),
( (x+1)-50, terrainFunction(x+1,z), z-50 ),
( (x+1)-50, terrainFunction(x+1,z+1), (z+1)-50 ),
(0, int(128+int(10*terrainFunction(x+0.5,z+0.5))),10 ) ) )

# Sometimes, place a rock, a teapot, or a tree.  Models I pulled from the
# internet.

if( random.random() < 0.005):
# Give the rocks a bit of variation in color in the roughtly grays.
importObject("rock.obj",
( x-50, terrainFunction(x,z)+0.1, z-50),
( int(random.random()*50)+50,
int(random.random()*50)+50,
int(random.random()*10)+50))
if( random.random() < 0.002):
# Teapots will be pastels.
importObject("teapot.obj",
( x-50, terrainFunction(x,z)+0.1, z-50),
( int(random.random()*128)+128,
int(random.random()*128)+128,
int(random.random()*128)+128),0.3)
if( random.random() < 0.0015):
importObject("tree.obj", ( x-50, terrainFunction(x,z)+3, z-50), (108,60,10),2)

for frame in range(300):

# Gray background
im = Image.new('RGB', (800, 500), color=(100,100,100))

draw = ImageDraw.Draw(im)

# This function sorts the trianges so the one with a
# largest average z-value come first, so they may be
# drawn back to front.
triangles = sorted(triangles, key = lambda t: t + t + t, reverse=True )

for triangle in triangles:
# if the triangle (or any part) is behind us, don't draw it.
if triangle > 0 and triangle > 0 and triangle > 0:

# the idea here is to scale the x and y by the z.
#   Roughtly: drawn x = x / z, drawn y = y / z.
#   Some additional terms are needed to scale.

draw.polygon([
(
(triangle /
(focallength * triangle))*scale + xoffset,
(-triangle /
(focallength * triangle )) * scale + yoffset
),
(
(triangle /
(focallength * triangle))*scale + xoffset,
(-triangle /
(focallength * triangle )) * scale + yoffset
),
(
(triangle /
(focallength * triangle))*scale + xoffset,
(-triangle /
(focallength * triangle )) * scale + yoffset
),
(
(triangle /
(focallength * triangle))*scale + xoffset,
(-triangle /
(focallength * triangle )) * scale + yoffset
) ],
fill = triangle
)

# I'm reversing the order so you come toward the house.
im.save('scene{:04}.png'.format(299-frame))

# After each frame, rotate a bit, move backward a bit and move up a bit.
theta = 0.01
movez = 0.1
movey = -0.03

for index in range(len(triangles)):
triangle = triangles[index]
newvertexes = []
for x in range(3):
vertex = triangles[index][x]
d = (vertex ** 2 + vertex ** 2) ** 0.5
if d > 0:
if(vertex > 0): newtheta = acos(vertex/d) + theta
else: newtheta = - acos(vertex/d) + theta
newvertexes.append( ( sin ( newtheta ) * d,
vertex+movey,
cos ( newtheta ) * d + movez  ))
else:
newvertexes.append( (0,vertex+movey,movez)  )
newvertexes.append ( triangles[index] )
triangles[index] = tuple(newvertexes)

```