Adding simple perspective to a 3D renderer in pygame

Viewed 157

I have created a 3D renderer in pyGame however I would now like to add perspective. I have been trying for a while now and can't seem to figure it out.

I have read that the most simplest form of perspective is to multiply the x and y coordinates by the inverse of the z coordinate so that the x and y are dependant on the z value. This means that the x and y distances should decrease with the z coordinate increasing and x and y should increase with z decreasing. I have managed to get this to work slightly however it seems to accumulate so when I spin the box left and right the back of the box gets very small and seems to accumulate a negative scale instead of remaining at a constant size with being at the set z distance.

Here is my code:

wireframe.py:

class Wireframe:

    def __init__(self):
        self.nodes = np.zeros((0,4))
        self.edges = []

    def addNodes(self, node_array):

        ones_column = np.ones((len(node_array), 1))
        ones_added = np.hstack((node_array, ones_column))
        self.nodes = np.vstack((self.nodes, ones_added))
        

    def addEdges(self, edgeList):
        self.edges += edgeList

    def outputNodes(self):
        print("\n --- Nodes ---")

        for i, (x, y, z, _) in enumerate(self.nodes):
            print(" %d: (%.2f, %.2f, %.2f)" % (i, node.x, node.y, node.z))

    def outputEdges(self):

        print("\n --- Edges ---")

        for i, (node1, node2) in enumerate(self.edges):
            print(" %d: %d -> %d" % (i, node1, node2))

    def translate(self, axis, d):
        if axis in ['x', 'y', 'z']:
            for node in self.nodes:
                setattr(node, axis, getattr(node, axis) + d)

    def scale(self, centre_x, centre_y, scale):

        for node in self.nodes:
            node.x = centre_x + scale * (node.x - centre_x)
            node.y = centre_y + scale * (node.y - centre_y)
            node.z *= scale

    def findCentre(self):

        num_nodes = len(self.nodes)
        meanX = sum([node.x for node in self.nodes]) / num_nodes
        meanY = sum([node.y for node in self.nodes]) / num_nodes
        meanZ = sum([node.z for node in self.nodes]) / num_nodes

        return (meanX, meanY, meanZ)

    def rotateZ(self, centre, radians):
        cx, cy, cz = centre

        for node in self.nodes:
            x = node.x - cx
            y = node.y - cy
            d = math.hypot(y,x)
            theta = math.atan2(y,x) + radians
            node.x = cx + d * math.cos(theta)
            node.y = cy + d * math.sin(theta)

    def rotateX(self, centre, radians):
        cx, cy, cz = centre
        for node in self.nodes:
            y = node.y - cy
            z = node.z - cz
            d = math.hypot(y,z)
            theta = math.atan2(y, z) + radians
            node.z = cz + d * math.cos(theta)
            node.y = cy + d * math.sin(theta)

    def rotateY(self, centre, radians):
        cx, cy, cz = centre
        for node in self.nodes:
            x = node.x - cx
            z = node.z - cz
            d = math.hypot(x, z)
            theta = math.atan2(x, z) + radians

            node.z = cz + d * math.cos(theta)
            node.x = cx + d * math.sin(theta)

    def transform(self, matrix):
        self.nodes = np.dot(self.nodes, matrix)

    def transform_for_perspective(self):

        for node in self.nodes:
            print(node[0], node[1], node[2])
            if node[2] != 0:

                node[0] = node[0]*(1/(1-(node[2]*0.00005)))
                node[1] = node[1]*(1/(1-(node[2]*0.00005)))
                node[2] = node[2]*1

    def translationMatrix(self, dx=0, dy=0, dz=0):

        return np.array([[1,0,0,0],
                         [0,1,0,0],
                         [0,0,1,0],
                         [dx,dy,dz,1]])

    def scaleMatrix(self, sx=0, sy=0, sz=0):

        return np.array([[sx, 0, 0, 0], 
                         [0, sy, 0, 0],
                         [0, 0, sz, 0],
                         [0, 0, 0, 1]])

    def rotateXMatrix(self, radians):

        c = np.cos(radians)
        s = np.sin(radians)

        return np.array([[1,0,0,0],
                         [0,c,-s,0],
                         [0,s,c,0],
                         [0,0,0,1]])

    def rotateYMatrix(self, radians):

        c = np.cos(radians)
        s = np.sin(radians)

        return np.array([[c,0,s,0],
                         [0,1,0,0],
                         [-s,0,c,0],
                         [0,0,0,1]])

    def rotateZMatrix(self, radians):

        c = np.cos(radians)
        s = np.sin(radians)

        return np.array([[c,-s, 0, 0],
                         [s,c,0,0],
                         [0,0,1,0],
                         [0,0,0,1]])

    def movCamera(self, tilt, pan):

        return np.array([[1,0,0,200],
                         [0,1,0,0],
                         [pan,tilt,1,0],
                         [0,0,0,0]])

projectionViewer.py

from wireframe import *
import pygame
import numpy as np

class ProjectionViewer:

    ''' Displays 3D Objects on a Pygame Screen '''

    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.screen = pygame.display.set_mode((width, height))
        pygame.display.set_caption('Wireframe Display')
        self.background = (10,10,50)


        self.wireframes = {}
        self.displayNodes = True
        self.displayEdges = True
        self.nodeColour = (255,255,255)
        self.edgeColour = (200,200,200)
        self.nodeRadius = 4

    def run(self):

        key_to_function = {
        pygame.K_LEFT: (lambda x: x.translateAll([-10, 0, 0])),
        pygame.K_RIGHT:(lambda x: x.translateAll([ 10, 0, 0])),
        pygame.K_DOWN: (lambda x: x.translateAll([0,  10, 0])),
        pygame.K_UP:   (lambda x: x.translateAll([0, -10, 0])),

        pygame.K_a: (lambda x: x.rotate_about_Center('Y', -0.08)),
        pygame.K_d: (lambda x: x.rotate_about_Center('Y', 0.08)),
        pygame.K_w: (lambda x: x.rotate_about_Center('X', -0.08)),
        pygame.K_s: (lambda x: x.rotate_about_Center('X', 0.08)),


        pygame.K_EQUALS: (lambda x: x.scale_centre([1.25,1.25,1.25])),
        pygame.K_MINUS: (lambda x: x.scale_centre([0.8,0.8,0.8])),

        pygame.K_q: (lambda x: x.rotateAll('X', 0.1)),
        pygame.K_z: (lambda x: x.rotateAll('Z', 0.1)),
        pygame.K_x: (lambda x: x.rotateAll('Z', -0.1)),
        pygame.K_p: (lambda x: x.perspectiveMode()),
        pygame.K_t: (lambda x: x.translate_Camera())
        
        }


        running = True
        flag = False

        while running:

            keys = pygame.key.get_pressed()

            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False

                
                
            if keys[pygame.K_LEFT]:
                key_to_function[pygame.K_LEFT](self)
            if keys[pygame.K_RIGHT]:
                key_to_function[pygame.K_RIGHT](self)
            if keys[pygame.K_DOWN]:
                key_to_function[pygame.K_DOWN](self)
            if keys[pygame.K_UP]:
                key_to_function[pygame.K_UP](self)
            if keys[pygame.K_EQUALS]:
                key_to_function[pygame.K_EQUALS](self)
            if keys[pygame.K_MINUS]:
                key_to_function[pygame.K_MINUS](self)
            if keys[pygame.K_LEFT]:
                key_to_function[pygame.K_LEFT](self)
            if keys[pygame.K_q]:
                key_to_function[pygame.K_q](self)
            if keys[pygame.K_w]:
                key_to_function[pygame.K_w](self)
            if keys[pygame.K_a]:
                key_to_function[pygame.K_a](self)
            if keys[pygame.K_s]:
                key_to_function[pygame.K_s](self)
            if keys[pygame.K_z]:
                key_to_function[pygame.K_z](self)
            if keys[pygame.K_x]:
                key_to_function[pygame.K_x](self)
            if keys[pygame.K_p]:
                key_to_function[pygame.K_p](self)
            if keys[pygame.K_t]:
                key_to_function[pygame.K_t](self)
            if keys[pygame.K_d]:
                key_to_function[pygame.K_d](self)

            self.display()
            pygame.display.flip()

    def addWireframe(self, name, wireframe):
        self.wireframes[name] = wireframe
        #translate to center
        wf = Wireframe()
        matrix = wf.translationMatrix(-self.width/2,-self.height/2,0)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)

        

        wf = Wireframe()
        matrix = wf.translationMatrix(self.width,self.height,0)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)


        

    def display(self):

        self.screen.fill(self.background)

        for wireframe in self.wireframes.values():
            if self.displayEdges:
                for n1, n2 in wireframe.edges:
                    pygame.draw.aaline(self.screen, self.edgeColour, wireframe.nodes[n1][:2], wireframe.nodes[n2][:2],1)

            wireframe.transform_for_perspective()

            if self.displayNodes:
                for node in wireframe.nodes:

                    pygame.draw.circle(self.screen, self.nodeColour, (int(node[0]), int(node[1])), self.nodeRadius, 0)




    def translateAll(self, vector):
        ''' Translate all wireframes along a given axis by d units '''
        wf = Wireframe()
        matrix = wf.translationMatrix(*vector)
        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)

    def scaleAll(self, vector):
        wf = Wireframe()
        matrix = wf.scaleMatrix(*vector)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)

    def rotateAll(self, axis, theta):

        wf = Wireframe()
        if axis == 'X':
            matrix = wf.rotateXMatrix(theta)
        elif axis == 'Y':
            matrix = wf.rotateYMatrix(theta)
        elif axis == 'Z':
            matrix = wf.rotateZMatrix(theta)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)
            #wireframe.transform_for_perspective()

    def moveCameraX(self,x,y):

        wf = Wireframe()

        matrix = wf.movCamera(x,y)
        print("test")

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)

    def moveCameraZ(self,x,y):

        wf = Wireframe()

        matrix = wf.testMat((0,0,val))

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)

    def perspectiveMode(self):

        #First translate the centre of screen to 0,0

        wf = Wireframe()
        matrix = wf.translationMatrix(-self.width/2,-self.height/2,0)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)

        #perform the perspectivecorrection

        wf = Wireframe()
        matrix = wf.translationMatrix(-self.width/2,-self.height/2,0)

        for wireframe in self.wireframes.values():
            matrix = wf.perspectiveCorrection(1.2)
            wireframe.transform(matrix)

        wf = Wireframe()
        matrix = wf.translationMatrix(self.width/2,self.height/2,0)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)


    def rotate_about_Center(self, Axis, theta):

        #First translate Centre of screen to 0,0

        wf = Wireframe()
        matrix = wf.translationMatrix(-self.width/2,-self.height/2,0)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)

        #Do Rotation
        wf = Wireframe()
        if Axis == 'X':
            matrix = wf.rotateXMatrix(theta)
        elif Axis == 'Y':
            matrix = wf.rotateYMatrix(theta)
        elif Axis == 'Z':
            matrix = wf.rotateZMatrix(theta)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)
            

        
        

        #Translate back to centre of screen

        wf = Wireframe()
        matrix = wf.translationMatrix(self.width/2,self.height/2,0)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)



        

        #Do perspective if needed

    def scale_centre(self, vector):

        #Transform center of screen to origin

        wf = Wireframe()
        matrix = wf.translationMatrix(-self.width/2,-self.height/2,0)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)

        #Scale the origin by vector

        wf = Wireframe()
        matrix = wf.scaleMatrix(*vector)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)

        wf = Wireframe()
        matrix = wf.translationMatrix(self.width/2,self.height/2,0)

        for wireframe in self.wireframes.values():
            wireframe.transform(matrix)



    def add_perspective(self):

        for wireframe in self.wireframes.values():
            for node in wireframe.nodes:
                if node[2] != 0:


                    print("Point ----------")
                    print("x node", node[0])
                    print("y node", node[1])
                    print("z node", node[2])

                    node[0] = node[0] + (10/node[2])
                    node[1] = node[1] + (10/node[2])

main.py

from projectionViewer import ProjectionViewer 
import wireframe
import numpy as np

cube = wireframe.Wireframe()

cube_nodes = [(x, y, z) for x in (-100, 100) for y in (-100, 100) for z in (-100, 100)]

print(cube_nodes)

cube.addNodes(np.array(cube_nodes))
cube.addEdges([(n, n + 4) for n in range(0, 4)])
cube.addEdges([(n, n + 1) for n in range(0, 8, 2)])
cube.addEdges([(n, n + 2) for n in (0, 1, 4, 5)])

pv = ProjectionViewer(1200, 1000)

pv.addWireframe('cube', cube)



pv.run()

The code that does the multiplying is in the wireframe file and the transform_for_perspective() function.

def transform_for_perspective(self):

        for node in self.nodes:
            print(node[0], node[1], node[2])
            if node[2] != 0:

                node[0] = node[0]*(1/(1-(node[2]*0.00005)))
                node[1] = node[1]*(1/(1-(node[2]*0.00005)))
                node[2] = node[2]*1

If anyone could tell me where I am going wrong and explain in which order I need to call the perspective matrix, i.e rotation then perspective or perspective and then rotation.

Also, Because Pygame starts at (0,0) in the top left corner this means that if I want to rotate about the centre of the screen I have to translate the centre of the screen, perform the rotation matrix and then translate it back to the centre. What does this mean for perspective? do I have to translate the centre of the screen to the top left and then perform the perspective matrix and then translate it back again?

Any help would be much appreciated.

1 Answers

The transformation that you are applying in transform_for_perspective should only be applied once. However, it seems that you are calling it on every frame, and as it stores the output in the same variable (self.nodes) it is applied many times. Consider saving the output of that transformation in a new field (such as self.perspective_nodes).

Also, the transformation was not working for me, I tried to do some variations and came up with this:

class Wireframe:

    def __init__(self):
        self.nodes = np.zeros((0, 4))
        self.perspective_nodes = None
        self.edges = []

    ....

    def transform_for_perspective(self, center):
        self.perspective_nodes = self.nodes.copy()
        for i in range(len(self.nodes)):
            node = self.nodes[i]
            p_node = self.perspective_nodes[i]
            print(node[0], node[1], node[2])
            if node[2] != 0:
                p_node[0] = center[0] + (node[0]-center[0])*250/(200-(node[2]))
                p_node[1] = center[1] + (node[1]-center[1])*250/(200-(node[2]))
                p_node[2] = node[2] * 1

You also need to modify display in projectionViewer:

    def display(self):

        self.screen.fill(self.background)

        for wireframe in self.wireframes.values():

            wireframe.transform_for_perspective((self.width/2, self.height/2))

            if self.displayNodes:
                for node in wireframe.perspective_nodes:

                    pygame.draw.circle(self.screen, self.nodeColour, (int(
                        node[0]), int(node[1])), self.nodeRadius, 0)
            if self.displayEdges:
                for n1, n2 in wireframe.edges:
                    pygame.draw.aaline(
                        self.screen, self.edgeColour, wireframe.perspective_nodes[n1][:2], wireframe.perspective_nodes[n2][:2], 1)
Related