3D Graphics Engines, Python3

Demos for Beginners, by Hannah

back to projects

Teapot Silouette

Rotating Teapot

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.

# ----------------------- except 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[0] == 'v':
		number = line[2:].split()
		vertexes.append( ( float(number[0]),
		                  -float(number[1]),
				   float(number[2])))
	if line[0] == 'f':
		number = line[2:].split()
		# Vertexes are numbered from 1 in the file, but indexed from 0 in Python.
		triangles.append( (
			vertexes[int(number[0])-1],
			vertexes[int(number[1])-1],
			vertexes[int(number[2])-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[0][0]-xoffset) * scale,
				(triangle[0][1] - yoffset) * scale ),
	               ( (triangle[1][0]-xoffset) *scale,
		       		(triangle[1][1] - yoffset) * scale ),
		       ( (triangle[2][0]-xoffset) * scale,
		       		(triangle[2][1] - yoffset) * scale),
		       ( (triangle[0][0]-xoffset) * scale,
		       		(triangle[0][1] - 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[0] == 'v':
		number = line[2:].split()
		vertexes.append( ( float(number[0]),
		                  -float(number[1]),
				   float(number[2])))
	if line[0] == 'f':
		number = line[2:].split()
		# Vertexes are numbered from 1 in the file, but indexed from 0 in Python.
		triangles.append( (
			vertexes[int(number[0])-1],
			vertexes[int(number[1])-1],
			vertexes[int(number[2])-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[0][0]-xoffset) * scale,
				(triangle[0][1] - yoffset) * scale ),
	               ( (triangle[1][0]-xoffset) *scale,
		       		(triangle[1][1] - yoffset) * scale ),
		       ( (triangle[2][0]-xoffset) * scale,
		       		(triangle[2][1] - yoffset) * scale),
		       ( (triangle[0][0]-xoffset) * scale,
		       		(triangle[0][1] - 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[0] ** 2 + vertex[2] ** 2) ** 0.5
			# guard against divie by zero.
			if r > 0:
				if(vertex[0] > 0):
					newtheta = acos(vertex[2]/r) + theta
				else:
					newtheta = - acos(vertex[2]/r) + theta
				newvertexes.append(
					( sin ( newtheta ) * d,
					vertex[1],
					cos ( newtheta ) * d) )
			else:
				newvertexes.append( (0,vertex[1],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[0] == 'v':
			number = line[2:].split()
			vertexes.append( ( float(number[0])*scale+placementVector[0],
					   float(number[1])*scale + placementVector[1],
					   float(number[2])*scale + placementVector[2]))
		if line[0] == 'f':
			number = line[2:].split()
			triangles.append( ( vertexes[int(number[0])+start],
					    vertexes[int(number[1])+start],
					    vertexes[int(number[2])+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[0][2] + t[1][2] + t[2][2], reverse=True )


	for triangle in triangles:
		# if the triangle (or any part) is behind us, don't draw it.
		if triangle[0][2] > 0 and triangle[1][2] > 0 and triangle[2][2] > 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[0][0] /
					(focallength * triangle[0][2]))*scale + xoffset,
		                 (-triangle[0][1] /
				  	(focallength * triangle[0][2] )) * scale + yoffset
				),
			        (
				 (triangle[1][0] /
			     		(focallength * triangle[1][2]))*scale + xoffset,
			         (-triangle[1][1] /
				  	(focallength * triangle[1][2] )) * scale + yoffset
				),
			        (
				 (triangle[2][0] /
			       		(focallength * triangle[2][2]))*scale + xoffset,
			         (-triangle[2][1] /
				  	(focallength * triangle[2][2] )) * scale + yoffset
				),
			        (
			         (triangle[0][0] /
			       		(focallength * triangle[0][2]))*scale + xoffset,
			         (-triangle[0][1] /
				  	(focallength * triangle[0][2] )) * scale + yoffset
				) ],
				 fill = triangle[3]
				)

	# 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[0] ** 2 + vertex[2] ** 2) ** 0.5
			if d > 0:
				if(vertex[0] > 0): newtheta = acos(vertex[2]/d) + theta
				else: newtheta = - acos(vertex[2]/d) + theta
				newvertexes.append( ( sin ( newtheta ) * d,
					vertex[1]+movey,
					cos ( newtheta ) * d + movez  ))
			else:
				newvertexes.append( (0,vertex[1]+movey,movez)  )
		newvertexes.append ( triangles[index][3] )
		triangles[index] = tuple(newvertexes)