├── README.md ├── images └── blackhole.png ├── main.py └── modules ├── blackhole.py ├── camera.py ├── color.py ├── constants.py ├── disk.py ├── engine.py ├── image.py ├── ray.py ├── scene.py ├── texture.py └── vector.py /README.md: -------------------------------------------------------------------------------- 1 | # Curved Spacetime Raytracer 2 | This project is based on my previous raytracer and extended to a curved spacetime by using an approximation of the Schwarzschild metric, which is a solution for Einstein's field equations that describes gravity in the vicinity of a radially simmetrical distribution of matter, without rotation or charge. 3 | 4 | ![BlackHole](images/blackhole.png) 5 | 6 | ## References 7 | 8 | * https://github.com/jrmiranda/raytracer 9 | * https://dmitrybrant.com/2018/12/11/ray-tracing-black-holes 10 | * http://rantonels.github.io/starless/ 11 | * https://locklessinc.com/articles/raytracing/ -------------------------------------------------------------------------------- /images/blackhole.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silvaan/blackhole_raytracer/d7a75e33a5812fd5a5b305ed30edf8565c8e4b1e/images/blackhole.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from modules.vector import Point 2 | from modules.color import Color 3 | from modules.blackhole import BlackHole 4 | from modules.disk import Disk 5 | from modules.camera import Camera 6 | from modules.scene import Scene 7 | from modules.engine import Engine 8 | 9 | c_origin = Point(0, 0.7, -9.0) 10 | c_focus = Point(0, 0, 0.0) 11 | 12 | bh = BlackHole(c_focus, 80) 13 | 14 | # You can specify a texture file for the accretion disk with `texture='filename.png'` or a color by `color=Color('#ffffff') (default)` 15 | disk = Disk(c_focus, 4.5*bh.radius, 16.2*bh.radius) 16 | 17 | scene = Scene( 18 | width = 500, 19 | height = 250, 20 | camera = Camera(c_origin, c_focus-c_origin, 1.2), 21 | blackhole = bh, 22 | disk = disk 23 | ) 24 | 25 | engine = Engine(scene) 26 | engine.render() 27 | 28 | engine.save('images/blackhole.png') -------------------------------------------------------------------------------- /modules/blackhole.py: -------------------------------------------------------------------------------- 1 | from modules.constants import G, c 2 | 3 | class BlackHole: 4 | def __init__(self, position, mass): 5 | self.position = position 6 | self.mass = mass 7 | self.radius = 2*self.mass*G/c**2 -------------------------------------------------------------------------------- /modules/camera.py: -------------------------------------------------------------------------------- 1 | from modules.vector import Vector 2 | from modules.ray import Ray 3 | 4 | class Camera: 5 | def __init__(self, origin, direction, focal_length): 6 | self.origin = origin 7 | self.direction = direction.normalize() 8 | self.focal_length = focal_length 9 | self.normal = self.origin + self.focal_length*self.direction 10 | self.right = Vector(1, 0, 0) 11 | self.up = self.normal.cross(self.right).normalize() 12 | 13 | def ray(self, x, y): 14 | point = self.normal + x*self.right + y*self.up 15 | return Ray(self.origin, point - self.origin) -------------------------------------------------------------------------------- /modules/color.py: -------------------------------------------------------------------------------- 1 | class Color: 2 | def __init__(self, value=(0, 0, 0)): 3 | if (isinstance(value, str)): 4 | self.r = int(value[1:3], 16) 5 | self.g = int(value[3:5], 16) 6 | self.b = int(value[5:7], 16) 7 | else: 8 | self.r = value[0] 9 | self.g = value[1] 10 | self.b = value[2] 11 | self.r = round(min(255, self.r)) 12 | self.g = round(min(255, self.g)) 13 | self.b = round(min(255, self.b)) 14 | self.value = (self.r, self.g, self.b) 15 | 16 | def __str__(self): 17 | return f'Color{self.value}' 18 | 19 | def __add__(self, other): 20 | return Color((self.r + other.r, self.g + other.g, self.b + other.b)) 21 | 22 | def __sub__(self, other): 23 | return Color((self.r - other.r, self.g - other.g, self.b - other.b)) 24 | 25 | def __mul__(self, other): 26 | return Color((self.r*other, self.g*other, self.b*other)) 27 | 28 | def __rmul__(self, other): 29 | return self.__mul__(other) 30 | 31 | def __truediv__(self, other): 32 | return Color((self.r/other, self.g/other, self.b/other)) 33 | 34 | def to_list(self): 35 | return list(self.value) -------------------------------------------------------------------------------- /modules/constants.py: -------------------------------------------------------------------------------- 1 | c = 1.0 2 | G = 2e-3 -------------------------------------------------------------------------------- /modules/disk.py: -------------------------------------------------------------------------------- 1 | from modules.color import Color 2 | 3 | class Disk: 4 | def __init__(self, origin, inner_r, outer_r, color=Color('#ffffff'), texture=None): 5 | self.origin = origin 6 | self.inner_r = inner_r 7 | self.outer_r = outer_r 8 | self.color = color 9 | if texture is not None: 10 | self.texture = Texture(texture, 2*self.outer_r, 2*self.outer_r) 11 | else: 12 | self.texture = None 13 | 14 | def is_in(self, point): 15 | r = (point-self.origin).norm 16 | return r <= self.outer_r and r >= self.inner_r 17 | 18 | def color_at(self, point): 19 | if self.texture is not None: 20 | x = point.x 21 | y = point.z 22 | return self.texture.get_color(x+self.outer_r, y+self.outer_r) 23 | else: 24 | return self.color -------------------------------------------------------------------------------- /modules/engine.py: -------------------------------------------------------------------------------- 1 | from modules.color import Color 2 | import numpy as np 3 | from PIL import Image 4 | 5 | class Engine: 6 | def __init__(self, scene, n_iter=200, dt=0.002): 7 | self.scene = scene 8 | self.n_iter = n_iter 9 | self.dt = dt 10 | 11 | def render(self): 12 | ratio = float(self.scene.width)/self.scene.height 13 | x0, x1 = -1.0, 1.0 14 | y0, y1 = -1.0/ratio, 1.0/ratio 15 | xstep, ystep = (x1-x0)/(self.scene.width-1), (y1-y0)/(self.scene.height-1) 16 | 17 | for j in range(self.scene.height): 18 | y = y0 + j*ystep 19 | 20 | if (j+1) % 10 == 0: 21 | print('line ' + str(j+1) + '/' + str(self.scene.height)) 22 | 23 | for i in range(self.scene.width): 24 | x = x0 + i*xstep 25 | ray = self.scene.camera.ray(x, y) 26 | self.scene.image.set_pixel(i, j, self.trace(ray)) 27 | 28 | self.output = Image.fromarray(self.scene.image.pixels.astype(np.uint8)) 29 | 30 | def trace(self, ray, depth=0): 31 | color = Color() 32 | for t in range(self.n_iter): 33 | r = self.scene.blackhole.position - ray.position 34 | a = 7.0e-3*(self.scene.blackhole.mass/r**5)*r.normalize() 35 | ray.accelerate(a) 36 | ray.step(t*self.dt) 37 | 38 | ray_bh_dist = (ray.position-self.scene.blackhole.position).norm 39 | 40 | if ray.crossed_xz and self.scene.disk.is_in(ray.cross_point): 41 | color = self.scene.disk.color_at(ray.position) 42 | break 43 | elif ray_bh_dist <= self.scene.blackhole.radius: 44 | break 45 | elif ray_bh_dist >= 15.0: 46 | break 47 | return color 48 | 49 | def save(self, filename): 50 | self.output.save(filename) -------------------------------------------------------------------------------- /modules/image.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | class Image: 4 | def __init__(self, width, height): 5 | self.width = width 6 | self.height = height 7 | self.pixels = np.zeros((height, width, 3)) 8 | 9 | def set_pixel(self, x, y, color): 10 | self.pixels[y, x] = color.value -------------------------------------------------------------------------------- /modules/ray.py: -------------------------------------------------------------------------------- 1 | from modules.constants import G, c 2 | from modules.vector import Vector 3 | 4 | class Ray: 5 | def __init__(self, origin, direction): 6 | self.origin = origin 7 | self.position = origin 8 | self.direction = direction.normalize() 9 | self.velocity = c*self.direction 10 | self.acceleration = Vector() 11 | self.total_time = 0 12 | self.crossed_xz = False 13 | 14 | def point(self, dist): 15 | return self.origin + dist*self.direction 16 | 17 | def accelerate(self, a): 18 | self.acceleration = a 19 | 20 | def step(self, t): 21 | self.prev_pos = self.position 22 | self.velocity += self.acceleration*t 23 | self.velocity = c*self.velocity.normalize() 24 | self.position += self.velocity*t + (self.acceleration/2)*t**2 25 | self.total_time += t 26 | 27 | self.crossed_xz = 0 <= max(self.prev_pos.y, self.position.y) and 0 >= min(self.prev_pos.y, self.position.y) 28 | if self.crossed_xz: 29 | a = self.prev_pos 30 | b = self.position 31 | l = b-a 32 | self.cross_point = Vector(a.x-(a.y/l.y)*l.x, 0, a.z-(a.y/l.y)*l.z) -------------------------------------------------------------------------------- /modules/scene.py: -------------------------------------------------------------------------------- 1 | from modules.image import Image 2 | 3 | class Scene: 4 | def __init__(self, camera, blackhole, disk, width, height): 5 | self.camera = camera 6 | self.width = width 7 | self.height = height 8 | self.image = Image(width, height) 9 | self.blackhole = blackhole 10 | self.disk = disk -------------------------------------------------------------------------------- /modules/texture.py: -------------------------------------------------------------------------------- 1 | class Texture: 2 | def __init__(self, im_file, width, height): 3 | self.pixels = np.array(pil.Image.open(im_file)) 4 | self.width = width 5 | self.height = height 6 | self.im_width = self.pixels.shape[0] 7 | self.im_height = self.pixels.shape[1] 8 | 9 | def get_color(self, x, y): 10 | x = min(int(round(x*(self.im_width-1)/self.width)), self.im_width-1) 11 | y = min(int(round(y*(self.im_height-1)/self.height)), self.im_height-1) 12 | return Color(self.pixels[x, y]) -------------------------------------------------------------------------------- /modules/vector.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | class Vector: 4 | def __init__(self, x=0, y=0, z=0): 5 | self.x = float(x) 6 | self.y = float(y) 7 | self.z = float(z) 8 | self.norm = np.sqrt(self.x**2+self.y**2+self.z**2) 9 | 10 | def normalize(self): 11 | return self / self.norm 12 | 13 | def dot(self, other): 14 | return self.x*other.x + self.y*other.y + self.z*other.z 15 | 16 | def cross(self, other): 17 | return Vector( 18 | self.y*other.z - self.z*other.y, 19 | self.z*other.x - self.x*other.z, 20 | self.x*other.y - self.y*other.x 21 | ) 22 | 23 | def __str__(self): 24 | return f'Vector({self.x}, {self.y}, {self.z})' 25 | 26 | def __add__(self, other): 27 | return Vector(self.x + other.x, self.y + other.y, self.z + other.z) 28 | 29 | def __sub__(self, other): 30 | return Vector(self.x - other.x, self.y - other.y, self.z - other.z) 31 | 32 | def __mul__(self, other): 33 | if isinstance(other, Vector): 34 | return self.dot(other) 35 | else: 36 | return Vector(self.x*other, self.y*other, self.z*other) 37 | 38 | def __rmul__(self, other): 39 | return self.__mul__(other) 40 | 41 | def __truediv__(self, other): 42 | return Vector(self.x/other, self.y/other, self.z/other) 43 | 44 | def __pow__(self, exp): 45 | return self*self 46 | 47 | def dist(self, other): 48 | return (self-other).norm 49 | 50 | def reflect(self, other): 51 | other = other.normalize() 52 | return self - 2 * (self * other) * other 53 | 54 | def rotate_x(self, theta): 55 | theta = theta*np.pi/180 56 | return Vector( 57 | self.x, 58 | np.cos(theta)*self.y-np.sin(theta)*self.z, 59 | np.sin(theta)*self.y+np.cos(theta)*self.z 60 | ) 61 | 62 | def rotate_z(self, theta): 63 | theta = theta*np.pi/180 64 | return Vector( 65 | np.cos(theta)*self.x-np.sin(theta)*self.y, 66 | np.sin(theta)*self.x+np.cos(theta)*self.y, 67 | self.z 68 | ) 69 | 70 | Point = Vector --------------------------------------------------------------------------------