├── LICENSE ├── camera.py ├── light.py ├── main.py ├── objects.py ├── ray.py ├── skybox.png ├── skybox.py └── vector.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Gpopcorn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /camera.py: -------------------------------------------------------------------------------- 1 | from math import tan, radians 2 | 3 | from vector import Vector 4 | from ray import Ray 5 | 6 | class Camera: 7 | __slots__ = ('position', 'screen_size', 'fov') 8 | 9 | def __init__(self, position: Vector, screen_size: Vector, fov: int | float = 60.0): 10 | self.position = position 11 | self.screen_size = screen_size 12 | self.fov = fov 13 | 14 | def __repr__(self) -> str: 15 | return f"Camera(position: {self.position}, screen_size: {self.screen_size}, fov: {self.fov})" 16 | 17 | def get_direction(self, x_y: Vector) -> Ray: 18 | xy = x_y - self.screen_size / 2 19 | z = self.screen_size.y / tan(radians(self.fov) / 2) 20 | return Ray(self.position, Vector(xy.x, xy.y, -z).normalize()) 21 | -------------------------------------------------------------------------------- /light.py: -------------------------------------------------------------------------------- 1 | from vector import Vector 2 | 3 | class Light: 4 | __slots__ = ('direction', 'strength') 5 | 6 | def __init__(self, direction: Vector, strength: int | float): 7 | self.direction = direction.normalize() 8 | self.strength = strength 9 | 10 | def __repr__(self) -> str: 11 | return f"Light(direction: {self.direction})" 12 | 13 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from sys import exit 2 | import pygame as pg 3 | 4 | from objects import Sphere, Checkerboard 5 | from camera import Camera 6 | from skybox import Skybox 7 | from vector import Vector 8 | from light import Light 9 | from ray import Ray 10 | 11 | pg.init() 12 | pg.display.init() 13 | 14 | screen_size = Vector(1280, 720) 15 | shadow_bias = 0.0001 16 | max_reflections = 3 17 | 18 | display = pg.display.set_mode((screen_size.x, screen_size.y)) 19 | pg.display.set_caption("Python Raytracer") 20 | 21 | pixel_array = pg.PixelArray(display) 22 | 23 | camera = Camera(Vector(0, 0, 0), screen_size, 90) 24 | skybox = Skybox("skybox.png") 25 | 26 | objects = [ 27 | Sphere(Vector(0, 0, -10), 2, Vector(1, 0, 0)), 28 | Sphere(Vector(5, 0, -15), 2, Vector(0, 1, 0)), 29 | Sphere(Vector(-5, 0, -15), 2, Vector(0, 0, 1)), 30 | Checkerboard(2, Vector(0, 0, 0), Vector(1, 1, 1)) 31 | ] 32 | 33 | light = Light(Vector(-1, 1, -1), 1) 34 | 35 | def trace_ray(ray: Ray) -> Vector: 36 | color = Vector(0, 0, 0) 37 | intersect, object = ray.cast(objects) 38 | normal = False 39 | if intersect: 40 | normal = object.get_normal(intersect) 41 | color = object.get_color(intersect) 42 | light_ray = Ray(intersect + normal * shadow_bias, -light.direction.normalize()) 43 | light_intersect, obstacle = light_ray.cast(objects) 44 | if light_intersect: 45 | color *= 0.1 / light.strength 46 | color *= normal.dot(-light.direction * light.strength) 47 | else: 48 | color = skybox.get_image_coords(ray.direction) 49 | return color, intersect, normal 50 | 51 | for y in range(pg.display.get_window_size()[1]): 52 | for x in range(pg.display.get_window_size()[0]): 53 | ray = camera.get_direction(Vector(x, y)) 54 | 55 | color, intersect, normal = trace_ray(ray) 56 | if intersect: 57 | reflection_ray = Ray(intersect + ray.direction.reflect(normal) * shadow_bias, ray.direction.reflect(normal)) 58 | reflection_color = Vector(0, 0, 0) 59 | reflection_times = 0 60 | for reflection in range(max_reflections): 61 | new_color, intersect, normal = trace_ray(reflection_ray) 62 | reflection_color += new_color 63 | reflection_times += 1 64 | if intersect: 65 | Ray(intersect + reflection_ray.direction.reflect(normal) * shadow_bias, reflection_ray.direction.reflect(normal)) 66 | else: 67 | break 68 | color += reflection_color / reflection_times 69 | else: 70 | color = skybox.get_image_coords(ray.direction) 71 | 72 | pixel_array[x, y] = color.to_rgb() 73 | 74 | pg.display.flip() 75 | for event in pg.event.get(): 76 | if event.type == pg.QUIT: 77 | pg.quit() 78 | exit() 79 | 80 | while True: 81 | for event in pg.event.get(): 82 | if event.type == pg.QUIT: 83 | pg.quit() 84 | exit() 85 | -------------------------------------------------------------------------------- /objects.py: -------------------------------------------------------------------------------- 1 | from math import sqrt 2 | 3 | from vector import Vector 4 | from ray import Ray 5 | 6 | class Sphere: 7 | __slots__ = ('center', 'radius', 'color') 8 | 9 | def __init__(self, center: Vector, radius: int | float, color: Vector): 10 | self.center = center 11 | self.radius = radius 12 | self.color = color 13 | 14 | def __repr__(self) -> str: 15 | return f"Sphere(center: {self.center}, radius: {self.radius}, color: {self.color})" 16 | 17 | def intersection(self, ray: Ray) -> bool | Vector: 18 | l = self.center - ray.origin 19 | adj = l.dot(ray.direction) 20 | d2 = l.dot(l) - (adj * adj) 21 | radius2 = self.radius * self.radius 22 | if d2 > radius2: 23 | return False 24 | thc = sqrt(radius2 - d2) 25 | t0 = adj - thc 26 | t1 = adj + thc 27 | if t0 < 0 and t1 < 0: 28 | return False 29 | distance = t0 if t0 < t1 else t1 30 | return ray.origin + ray.direction * distance 31 | 32 | def get_color(self, hit_position: Vector) -> Vector: 33 | return self.color 34 | 35 | def get_normal(self, hit_position: Vector) -> Vector: 36 | return (hit_position - self.center).normalize() 37 | 38 | class Checkerboard: 39 | __slots__ = ('y', 'color1', 'color2') 40 | 41 | def __init__(self, y: int | float, color1: Vector, color2: Vector): 42 | self.y = y 43 | self.color1 = color1 44 | self.color2 = color2 45 | 46 | def __repr__(self) -> str: 47 | return f"Checkerboard(y: {self.y}, color1: {self.color1}, color2: {self.color2})" 48 | 49 | def intersection(self, ray: Ray) -> bool | Vector: 50 | if ray.direction.y < 0: 51 | return False 52 | distance = self.y - ray.origin.y 53 | steps = distance / ray.direction.y 54 | return ray.origin + ray.direction * steps 55 | 56 | def get_color(self, hit_position: Vector) -> Vector: 57 | if round(hit_position.x) % 6 <= 2 and round(hit_position.z) % 6 <= 2 or \ 58 | round(hit_position.x) % 6 >= 3 and round(hit_position.z) % 6 >= 3: 59 | return self.color2 60 | return self.color1 61 | 62 | def get_normal(self, hit_position: Vector) -> Vector: 63 | return Vector(0, -1, 0) 64 | -------------------------------------------------------------------------------- /ray.py: -------------------------------------------------------------------------------- 1 | from vector import Vector 2 | 3 | class Ray: 4 | __slots__ = ('origin', 'direction') 5 | 6 | def __init__(self, origin: Vector, direction: Vector): 7 | self.origin = origin 8 | self.direction = direction.normalize() 9 | 10 | def __repr__(self) -> str: 11 | return f"Ray(origin: {self.origin}, direction: {self.direction})" 12 | 13 | def cast(self, objects: list) -> tuple: 14 | for object in objects: 15 | intersect = object.intersection(self) 16 | if intersect: 17 | return intersect, object 18 | return False, False 19 | 20 | -------------------------------------------------------------------------------- /skybox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gpopcorn/raytracer/905204f57aa94d30e8ba32e83e6e69d4881251a9/skybox.png -------------------------------------------------------------------------------- /skybox.py: -------------------------------------------------------------------------------- 1 | from math import atan2, asin, pi 2 | from numpy import asarray 3 | from PIL import Image 4 | 5 | from vector import Vector 6 | 7 | class Skybox: 8 | __slots__ = ('path', 'array', 'size') 9 | 10 | def __init__(self, path: str): 11 | self.path = path 12 | image = Image.open(self.path) 13 | self.array = asarray(image) 14 | self.size = image.size 15 | 16 | def get_image_coords(self, normal: Vector) -> Vector: 17 | u = 0.5 + atan2(normal.z, normal.x) / (2 * pi) 18 | v = 0.5 + asin(normal.y) / pi 19 | image_position = (int(u * self.size[0]), int(v * self.size[1])) 20 | color = self.array[image_position[1]][image_position[0]] 21 | return Vector(color[0], color[1], color[2]) / 255 22 | -------------------------------------------------------------------------------- /vector.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from math import sqrt 3 | 4 | class Vector: 5 | __slots__ = ('x', 'y', 'z') 6 | 7 | def __init__(self, x: int | float = 0.0, y: int | float = 0.0, z: int | float = 0.0): 8 | self.x = x 9 | self.y = y 10 | self.z = z 11 | 12 | def __repr__(self) -> str: 13 | return f"Vector(x: {self.x}, y: {self.y}, z: {self.z})" 14 | 15 | def __add__(self, addend: Vector | int | float) -> Vector: 16 | if type(addend) == int or type(addend) == float: 17 | return Vector(self.x + addend, self.y + addend, self.z + addend) 18 | elif type(addend) == Vector: 19 | return Vector(self.x + addend.x, self.y + addend.y, self.z + addend.z) 20 | print("WARNING! You are adding a vector with an unsupported variable type!") 21 | return Vector(self.x, self.y, self.z) 22 | 23 | def __sub__(self, subtrahend: Vector | int | float) -> Vector: 24 | if type(subtrahend) == int or type(subtrahend) == float: 25 | return Vector(self.x - subtrahend, self.y - subtrahend, self.z - subtrahend) 26 | elif type(subtrahend) == Vector: 27 | return Vector(self.x - subtrahend.x, self.y - subtrahend.y, self.z - subtrahend.z) 28 | print("WARNING! You are subtracting a vector with an unsupported variable type!") 29 | return Vector(self.x, self.y, self.z) 30 | 31 | def __mul__(self, factor: Vector | int | float) -> Vector: 32 | if type(factor) == int or type(factor) == float: 33 | return Vector(self.x * factor, self.y * factor, self.z * factor) 34 | elif type(factor) == Vector: 35 | return Vector(self.x * factor.x, self.y * factor.y, self.z * factor.z) 36 | print("WARNING! You are multiplying a vector with an unsupported variable type!") 37 | return Vector(self.x, self.y, self.z) 38 | 39 | def __truediv__(self, divisor: Vector | int | float) -> Vector: 40 | if type(divisor) == int or type(divisor) == float: 41 | return Vector(self.x / (divisor + 0.00000001), self.y / (divisor + 0.00000001), self.z / (divisor + 0.00000001)) 42 | elif type(divisor) == Vector: 43 | return Vector(self.x / (divisor.x + 0.00000001), self.y / (divisor.y + 0.00000001), self.z / (divisor.z + 0.00000001)) 44 | print("WARNING! You are dividing a vector with an unsupported variable type!") 45 | return Vector(self.x, self.y, self.z) 46 | 47 | def __pow__(self, exponent: Vector | int | float) -> Vector: 48 | if type(exponent) == int or type(exponent) == float: 49 | return Vector(self.x ** exponent, self.y ** exponent, self.z ** exponent) 50 | elif type(exponent) == Vector: 51 | return Vector(self.x ** exponent.x, self.y ** exponent.y, self.z ** exponent.z) 52 | print("WARNING! You are powering a vector with an unsupported variable type!") 53 | return Vector(self.x, self.y, self.z) 54 | 55 | def __gt__(self, other: Vector | int | float) -> bool: 56 | if type(other) == int or type(other) == float: 57 | return self.magnitude() > other 58 | if type(other) == Vector: 59 | return self.magnitude() > other.magnitude() 60 | print("WARNING! You are comparing a vector with an unsupported variable type!") 61 | return False 62 | 63 | def __lt__(self, other: Vector | int | float) -> bool: 64 | if type(other) == int or type(other) == float: 65 | return self.magnitude() < other 66 | if type(other) == Vector: 67 | return self.magnitude() < other.magnitude() 68 | print("WARNING! You are comparing a vector with an unsupported variable type!") 69 | return False 70 | 71 | def __gte__(self, other: Vector | int | float) -> bool: 72 | if type(other) == int or type(other) == float: 73 | return self.magnitude() >= other 74 | if type(other) == Vector: 75 | return self.magnitude() >= other.magnitude() 76 | print("WARNING! You are comparing a vector with an unsupported variable type!") 77 | return False 78 | 79 | def __lte__(self, other: Vector | int | float) -> bool: 80 | if type(other) == int or type(other) == float: 81 | return self.magnitude() <= other 82 | if type(other) == Vector: 83 | return self.magnitude() <= other.magnitude() 84 | print("WARNING! You are comparing a vector with an unsupported variable type!") 85 | return False 86 | 87 | def __eq__(self, other: Vector | int | float) -> bool: 88 | if type(other) == int or type(other) == float: 89 | return self.magnitude() == other 90 | if type(other) == Vector: 91 | return self.magnitude() == other.magnitude() 92 | print("WARNING! You are comparing a vector with an unsupported variable type!") 93 | return False 94 | 95 | def __neg__(self) -> Vector: 96 | return Vector(-self.x, -self.y, -self.z) 97 | 98 | def __pos__(self) -> Vector: 99 | return Vector(+self.x, +self.y, +self.z) 100 | 101 | def __float__(self) -> float: 102 | return float(self.magnitude()) 103 | 104 | def __int__(self) -> int: 105 | return int(self.magnitude()) 106 | 107 | def dot(self, factor: Vector | int | float) -> int | float: 108 | if type(factor) == int or type(factor) == float: 109 | return self.x * factor + self.y * factor + self.z * factor 110 | elif type(factor) == Vector: 111 | return self.x * factor.x + self.y * factor.y + self.z * factor.z 112 | print("WARNING! You are multiplying a vector with an unsupported variable type!") 113 | return self.x + self.y + self.z 114 | 115 | def cross(self, factor: Vector) -> Vector: 116 | return Vector((self.y * factor.z) - (self.z * factor.y), (self.z * factor.x) - (self.x * factor.z), (self.x * factor.y) - (self.y * factor.x)) 117 | 118 | def magnitude(self) -> int | float: 119 | return sqrt(self.x * self.x + self.y * self.y + self.z * self.z) 120 | 121 | def normalize(self) -> Vector: 122 | return self / self.magnitude() 123 | 124 | def reflect(self, normal: Vector) -> Vector: 125 | return self - normal * (self.dot(normal)) * 2 126 | 127 | def to_rgb(self) -> tuple: 128 | r, g, b = self.x, self.y, self.z 129 | if r < 0: r = 0 130 | elif r > 1: r = 1 131 | if g < 0: g = 0 132 | elif g > 1: g = 1 133 | if b < 0: b = 0 134 | elif b > 1: b = 1 135 | return (r * 255, g * 255, b * 255) 136 | --------------------------------------------------------------------------------