email me!
< back

Evan Harwin

Mathematician, Data Scientist, Programmer.

Raytracing in Python

This project aims to build a simple rendering pipeline for educational purposes. It's not the fastest renderer in the world and has no hardware acceleration, but good fun to design, build and play with. Theoretically this could render anything, as the framework isn't tied to the object it is rendering, but see below for a simple demo image:

It has a few key components; the three_space list, the Model class and the TwoSpace class. The three_space is a list of all of the models, each represented by an instance of the Model class, in our 'scene'. The Model class is what defines an object that we want to render and it is simply a combination of two functions, a material function and a bounding function. The TwoSpace class holds our final render, as well as a method that traces the line of sight for each pixel and works out what models, if any, intersect with it. This is done using the bounding function, and then the point of intersection is fed into the material function. The material function then takes this point of intersection and returns a colour.

The source code for the program is below:

raw file

            
from PIL import Image
import numpy as np

# An Object in the Three Dimensional Space
class Model:
    def __init__( self, bounding_function, material_function ):

        # function defining what it means for a ray to 'hit' this Object
        self.bounding_function = bounding_function

        # function that tells the ray how to respond to hitting this Object
        self.material_function = material_function

# The Plane Rendered onto the Screen
class TwoSpace:
    def __init__( self, size_x, size_y, ray_funct ):

        # the file to be 'rendered into'
        self.im = Image.open( 'im.png' )

        # no pixels in this file
        self.size_x, self.size_y = size_x, size_y

        # the field of view, defined as the angle subtended by the two vertical screen edges from the point of the camera
        self.fov = 1.2

        # localises the 'ray tracing' function
        self.ray_funct = ray_funct

    def render( self ):

        # run through all the pixels in final render, runs ray function for each
        pixels = []
        for y in range( 0, self.size_y ):
            for x in range( 0, self.size_x ):

                # well... esentially just 'slices' the fov angle up between the available pixels
                pixels.append( self.ray_funct( { 'direction': np.array([ x/self.size_x - 0.5, y/self.size_y - 0.5, 0.5 / np.tan( self.fov ) ]), 'origin': np.array([ 0, 0, 0 ]) } ) )

        # produce final render
        self.im.putdata( pixels )

        # display final render ( SLOW as mentioned in Pillow docs )
        self.im.show()

# takes a ray object ( with a direction and origin, both 3D vectors )
def trace( ray ):

    # checks agains *every* object in the three_space, this could be optimised a whole load with some thought?
    for object in three_space:

        # find possible points of intersection
        intersection = object.bounding_function( ray )

        # if found intersection
        if type( intersection ) == type( np.array([0]) ):
            return object.material_function( intersection )

    # otherwise output background colour
    else:
        return ( 0, 0, 0 )

def sphere_bounds( ray ):

    # defining sphere properties, probably a suggestion this should be a method for an object
    radius = 20
    position = np.array([ 0, 0, 30 ])

    # lets find some vectors for vector projection - also range of a ray definitely shouldn't be hard coded here
    ray_to_sphere_centre = np.subtract( ray[ 'origin' ],  position )
    ray_vect = ray[ 'direction' ] * 10000 - ray[ 'origin' ]

    # find normal to sphere perpendicular to ray, is a vector rejection of ray origin to sphere centre vector onto ray vector
    perpendicular = ray_to_sphere_centre - np.multiply( ( ray_to_sphere_centre.dot( ray_vect ) / ray_vect.dot( ray_vect ) ), ray_vect )

    if perpendicular.dot( perpendicular ) < radius ** 2:

        # return point of intersection - my first sqrt in this function I think I did a good :P
        return ( radius ** 2 - perpendicular.dot( perpendicular ) ) ** 0.5 - (ray_to_sphere_centre - perpendicular)

    else:
        return 0

# is this a shader yet?
def point_light_at_zero( point ):
    intensity = point.dot( point )
    return ( int( (intensity/8) ), 0, int( (intensity/8) ) )

three_space = [ Model( sphere_bounds, point_light_at_zero ) ]