You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
362 lines
11 KiB
Python
362 lines
11 KiB
Python
"""3d rotating polyhedra. 2016 badge competition winner, ported for 2018!"""
|
|
|
|
___title___ = "3D Spin"
|
|
___license___ = "MIT"
|
|
___categories___ = ["Demo"]
|
|
___dependencies___ = ["app", "ugfx_helper", "sleep", "buttons"]
|
|
|
|
import ugfx
|
|
from tilda import Buttons
|
|
import math
|
|
from uos import listdir
|
|
import time
|
|
# from imu import IMU
|
|
import gc
|
|
# import pyb
|
|
import app
|
|
|
|
app_path = './3dspin'
|
|
|
|
from math import sqrt
|
|
|
|
class Vector3D:
|
|
def __init__(self, x=0.0, y=0.0, z=0.0):
|
|
self.x = x
|
|
self.y = y
|
|
self.z = z
|
|
|
|
def magnitude(self):
|
|
return sqrt(self.x*self.x+self.y*self.y+self.z*self.z)
|
|
|
|
def __sub__(self, v):
|
|
return Vector3D(self.x-v.x, self.y-v.y, self.z-v.z)
|
|
|
|
def normalize(self):
|
|
mag = self.magnitude()
|
|
if (mag > 0.0):
|
|
self.x /= mag
|
|
self.y /= mag
|
|
self.z /= mag
|
|
else:
|
|
raise Exception('*** Vector: error, normalizing zero vector! ***')
|
|
|
|
def cross(self, v): #cross product
|
|
return Vector3D(self.y*v.z-self.z*v.y, self.z*v.x-self.x*v.z, self.x*v.y-self.y*v.x)
|
|
|
|
|
|
#The layout of the matrix (row- or column-major) matters only when the user reads from or writes to the matrix (indexing). For example in the multiplication function we know that the first components of the Matrix-vectors need to be multiplied by the vector. The memory-layout is not important
|
|
class Matrix:
|
|
''' Column-major order '''
|
|
|
|
def __init__(self, createidentity=True):# (2,2) creates a 2*2 Matrix
|
|
# if rows < 2 or cols < 2:
|
|
# raise Exception('*** Matrix: error, getitem((row, col)), row, col problem! ***')
|
|
self.rows = 4
|
|
self.cols = 4
|
|
self.m = [[0.0]*self.rows for x in range(self.cols)]
|
|
|
|
#If quadratic matrix then create identity one
|
|
if createidentity:
|
|
for i in range(self.rows):
|
|
self.m[i][i] = 1.0
|
|
|
|
def mul(self, right):
|
|
if isinstance(right, Matrix):
|
|
r = Matrix(False)
|
|
for i in range(self.rows):
|
|
for j in range(right.cols):
|
|
for k in range(self.cols):
|
|
r.m[i][j] += self.m[i][k]*right.m[k][j]
|
|
return r
|
|
elif isinstance(right, Vector3D): #Translation: the last column of the matrix. Remains unchanged due to the the fourth coord of the vector (1).
|
|
# if self.cols == 4:
|
|
r = Vector3D()
|
|
addx = addy = addz = 0.0
|
|
if self.rows == self.cols == 4:
|
|
addx = self.m[0][3]
|
|
addy = self.m[1][3]
|
|
addz = self.m[2][3]
|
|
r.x = self.m[0][0]*right.x+self.m[0][1]*right.y+self.m[0][2]*right.z+addx
|
|
r.y = self.m[1][0]*right.x+self.m[1][1]*right.y+self.m[1][2]*right.z+addy
|
|
r.z = self.m[2][0]*right.x+self.m[2][1]*right.y+self.m[2][2]*right.z+addz
|
|
|
|
#In 3D game programming we use homogenous coordinates instead of cartesian ones in case of Vectors in order to be able to use them with a 4*4 Matrix. The 4th coord (w) is not included in the Vector-class but gets computed on the fly
|
|
w = self.m[3][0]*right.x+self.m[3][1]*right.y+self.m[3][2]*right.z+self.m[3][3]
|
|
if (w != 1 and w != 0):
|
|
r.x = r.x/w;
|
|
r.y = r.y/w;
|
|
r.z = r.z/w;
|
|
return r
|
|
else:
|
|
raise Exception('*** Matrix: error, matrix multiply with not matrix, vector or int or float! ***')
|
|
|
|
def loadObject(filename):
|
|
print(filename)
|
|
if (".obj" in filename):
|
|
loadObj(filename)
|
|
if (".dat" in filename):
|
|
loadDat(filename)
|
|
|
|
def loadDat(filename):
|
|
global obj_vertices
|
|
global obj_faces
|
|
obj_vertices = []
|
|
obj_faces = []
|
|
f = open(app_path + "/" + filename)
|
|
for line in f:
|
|
if line[:2] == "v ":
|
|
parts = line.split(" ")
|
|
obj_vertices.append(
|
|
Vector3D(
|
|
float(parts[1]),
|
|
float(parts[2]),
|
|
float(parts[3])
|
|
)
|
|
)
|
|
gc.collect()
|
|
elif line[:2] == "f ":
|
|
parts = line.split(" ")
|
|
face = []
|
|
for part in parts[1:]:
|
|
face.append(int(part.split("/",1)[0])-1)
|
|
obj_faces.append(face)
|
|
gc.collect()
|
|
f.close()
|
|
|
|
def loadObj(filename):
|
|
global obj_vertices
|
|
global obj_faces
|
|
obj_vertices = []
|
|
obj_faces = []
|
|
f = open(app_path + "/" + filename)
|
|
for line in f:
|
|
if line[:2] == "v ":
|
|
parts = line.split(" ")
|
|
obj_vertices.append(
|
|
Vector3D(
|
|
float(parts[1]),
|
|
float(parts[2]),
|
|
float(parts[3])
|
|
)
|
|
)
|
|
gc.collect()
|
|
elif line[:2] == "f ":
|
|
parts = line.split(" ")
|
|
face = []
|
|
for part in parts[1:]:
|
|
face.append(int(part.split("/",1)[0])-1)
|
|
obj_faces.append(face)
|
|
gc.collect()
|
|
f.close()
|
|
|
|
def toScreenCoords(pv):
|
|
px = int((pv.x+1)*0.5*240)
|
|
py = int((1-(pv.y+1)*0.5)*320)
|
|
return [px, py]
|
|
|
|
def createCameraMatrix(x,y,z):
|
|
camera_transform = Matrix()
|
|
camera_transform.m[0][3] = x
|
|
camera_transform.m[1][3] = y
|
|
camera_transform.m[2][3] = z
|
|
return camera_transform
|
|
|
|
def createProjectionMatrix(horizontal_fov, zfar, znear):
|
|
s = 1/(math.tan(math.radians(horizontal_fov/2)))
|
|
proj = Matrix()
|
|
proj.m[0][0] = s * (320/240) # inverse aspect ratio
|
|
proj.m[1][1] = s
|
|
proj.m[2][2] = -zfar/(zfar-znear)
|
|
proj.m[3][2] = -1.0
|
|
proj.m[2][3] = -(zfar*znear)/(zfar-znear)
|
|
return proj
|
|
|
|
def createRotationMatrix(x_rotation, y_rotation, z_rotation):
|
|
rot_x = Matrix()
|
|
rot_x.m[1][1] = rot_x.m[2][2] = math.cos(x_rotation)
|
|
rot_x.m[2][1] = math.sin(x_rotation)
|
|
rot_x.m[1][2] = -rot_x.m[2][1]
|
|
|
|
rot_y = Matrix()
|
|
rot_y.m[0][0] = rot_y.m[2][2] = math.cos(y_rotation)
|
|
rot_y.m[0][2] = math.sin(y_rotation)
|
|
rot_y.m[2][0] = -rot_y.m[0][2]
|
|
|
|
rot_z = Matrix()
|
|
rot_z.m[0][0] = rot_z.m[1][1] = math.cos(z_rotation)
|
|
rot_z.m[1][0] = math.sin(z_rotation)
|
|
rot_z.m[0][1] = -rot_z.m[1][0]
|
|
|
|
return rot_z.mul(rot_x).mul(rot_y)
|
|
|
|
def normal(face, vertices, normalize = True):
|
|
# Work out the face normal for lighting
|
|
normal = (vertices[face[1]]-vertices[face[0]]).cross(vertices[face[2]]-vertices[face[0]])
|
|
if normalize == True:
|
|
normal.normalize()
|
|
return normal
|
|
|
|
def clear_screen():
|
|
# Selectively clear the screen by re-rendering the previous frame in black
|
|
global last_polygons
|
|
global last_mode
|
|
for poly in last_polygons:
|
|
if last_mode == FLAT:
|
|
ugfx.fill_polygon(0,0, poly, ugfx.BLACK)
|
|
ugfx.polygon(0,0, poly, ugfx.BLACK)
|
|
|
|
def render(mode, rotation):
|
|
# Rotate all the vertices in one go
|
|
vertices = [rotation.mul(vertex) for vertex in obj_vertices]
|
|
# Calculate normal for each face (for lighting)
|
|
if mode == FLAT:
|
|
face_normal_zs = [normal(face, vertices).z for face in obj_faces]
|
|
# Project (with camera) all the vertices in one go as well
|
|
vertices = [camera_projection.mul(vertex) for vertex in vertices]
|
|
# Calculate projected normals for each face
|
|
if mode != WIREFRAME:
|
|
proj_normal_zs = [normal(face, vertices, False).z for face in obj_faces]
|
|
# Convert to screen coordinates all at once
|
|
# We could do this faster by only converting vertices that are
|
|
# in faces that will be need rendered, but it's likely that test
|
|
# would take longer.
|
|
vertices = [toScreenCoords(v) for v in vertices]
|
|
# Render the faces to the screen
|
|
vsync()
|
|
clear_screen()
|
|
|
|
global last_polygons
|
|
global last_mode
|
|
last_polygons = []
|
|
last_mode = mode
|
|
|
|
for index in range(len(obj_faces)):
|
|
# Only render things facing towards us (unless we're in wireframe mode)
|
|
if (mode == WIREFRAME) or (proj_normal_zs[index] > 0):
|
|
# Convert polygon
|
|
poly = [vertices[v] for v in obj_faces[index]]
|
|
# Calculate colour and render
|
|
ugcol = ugfx.WHITE
|
|
if mode == FLAT:
|
|
# Simple lighting calculation
|
|
colour5 = int(face_normal_zs[index] * 31)
|
|
colour6 = int(face_normal_zs[index] * 63)
|
|
# Create a 5-6-5 grey
|
|
ugcol = (colour5 << 11) | (colour6 << 5) | colour5
|
|
# Render polygon
|
|
ugfx.fill_polygon(0,0, poly, ugcol)
|
|
# Always draw the wireframe in the same colour to fill gaps left by the
|
|
# fill_polygon method
|
|
ugfx.polygon(0,0, poly, ugcol)
|
|
last_polygons.append(poly)
|
|
|
|
def vsync():
|
|
None
|
|
# while(tear.value() == 0):
|
|
# pass
|
|
# while(tear.value()):
|
|
# pass
|
|
|
|
def calculateRotation(smoothing, accelerometer):
|
|
# Keep a list of recent rotations to smooth things out
|
|
global x_rotation
|
|
global z_rotation
|
|
# First, pop off the oldest rotation
|
|
# if len(x_rotations) >= smoothing:
|
|
# x_rotations = x_rotations[1:]
|
|
# if len(z_rotations) >= smoothing:
|
|
# z_rotations = z_rotations[1:]
|
|
# Now append a new rotation
|
|
pi_2 = math.pi / 2
|
|
#x_rotations.append(-accelerometer['z'] * pi_2)
|
|
#z_rotations.append(accelerometer['x'] * pi_2)
|
|
# Calculate rotation matrix
|
|
return createRotationMatrix(
|
|
# this averaging isn't correct in the first <smoothing> frames, but who cares
|
|
math.radians(x_rotation),
|
|
math.radians(y_rotation),
|
|
math.radians(z_rotation)
|
|
)
|
|
print("Hello 3DSpin")
|
|
|
|
# Initialise hardware
|
|
ugfx.init()
|
|
ugfx.clear(ugfx.BLACK)
|
|
# imu=IMU()
|
|
# buttons.init()
|
|
|
|
# Enable tear detection for vsync
|
|
# ugfx.enable_tear()
|
|
# tear = pyb.Pin("TEAR", pyb.Pin.IN)
|
|
#ugfx.set_tear_line(1)
|
|
|
|
print("Graphics initalised")
|
|
|
|
# Set up static rendering matrices
|
|
camera_transform = createCameraMatrix(0, 0, -5.0)
|
|
proj = createProjectionMatrix(45.0, 100.0, 0.1)
|
|
camera_projection = proj.mul(camera_transform)
|
|
|
|
print("Camera initalised")
|
|
|
|
# Get the list of available objects, and load the first one
|
|
obj_vertices = []
|
|
obj_faces = []
|
|
print("available objects: {}", listdir(app_path))
|
|
objects = [x for x in listdir(app_path) if (((".obj" in x) | (".dat" in x)) & (x[0] != "."))]
|
|
selected = 0
|
|
loadObject(objects[selected])
|
|
|
|
print("loaded object {}", objects[selected])
|
|
|
|
# Set up rotation tracking arrays
|
|
x_rotation = 0
|
|
z_rotation = 0
|
|
y_rotation = 0
|
|
# Smooth rotations over 5 frames
|
|
smoothing = 5
|
|
|
|
# Rendering modes
|
|
BACKFACECULL = 1
|
|
FLAT = 2
|
|
WIREFRAME = 3
|
|
# Start with backface culling mode
|
|
mode = BACKFACECULL
|
|
|
|
last_polygons = []
|
|
last_mode = WIREFRAME
|
|
|
|
# Main loop
|
|
run = True
|
|
while run:
|
|
gc.collect()
|
|
# Render the scene
|
|
render(
|
|
mode,
|
|
calculateRotation(smoothing, None)
|
|
)
|
|
# Button presses
|
|
y_rotation += 5
|
|
x_rotation += 3
|
|
z_rotation += 1
|
|
if Buttons.is_pressed(Buttons.JOY_Left):
|
|
y_rotation -= 5
|
|
if Buttons.is_pressed(Buttons.JOY_Right):
|
|
y_rotation += 5
|
|
if Buttons.is_pressed(Buttons.JOY_Center):
|
|
y_rotation = 0
|
|
if Buttons.is_pressed(Buttons.BTN_B):
|
|
selected += 1
|
|
if selected >= len(objects):
|
|
selected = 0
|
|
loadObject(objects[selected])
|
|
time.sleep_ms(500) # Wait a while to avoid skipping ahead if the user still has the button down
|
|
if Buttons.is_pressed(Buttons.BTN_A):
|
|
mode += 1
|
|
if mode > 3:
|
|
mode = 1
|
|
time.sleep_ms(500) # Wait a while to avoid skipping ahead if the user still has the button down
|
|
if Buttons.is_pressed(Buttons.BTN_Menu):
|
|
run = False
|
|
app.restart_to_default()
|