-
Notifications
You must be signed in to change notification settings - Fork 0
/
raytracer.py
286 lines (252 loc) · 15.3 KB
/
raytracer.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# -*- coding: utf-8 -*-
"""RayTracer.ipynb
Automatically generated by Colaboratory.
Original file is located at
https://colab.research.google.com/drive/1uZwiH6rd_3I_LKEvOqf0FzFRvsoZl486
"""
# Commented out IPython magic to ensure Python compatibility.
import numpy as np
import matplotlib.pyplot as plt
#!pip install ipython-autotime
# %load_ext autotime
"""Here we have defined the variables for the width and height of our viewing window , can change it accordingly"""
width = 400
"""Defining the width size of our window"""
height = 300
"""Defining the height of our window """
def normalize(vector):
"""The normalize function of numpy is used to get the unit vector of the vector that we supply to it as an argument , here x."""
vector /= np.linalg.norm(vector)
return vector
def intersect_plane(camera, direction, P_plane, N_plane):
"""<li> O is our camera</li>
<li> Q is the pixel at which our camera is pointing </li>
<li> N_plane is our normal vector of the plane</li>
<li> direction is our point of intersection with the plane. Hence OD will be ray directon here. </li>
<li> P_plane is our random point on the plane. The plane will be represented as P,N </li>
"""
# Return the distance from O to the intersection of the ray (O, D) with the
# plane (P_plane, N_plane), or +inf if there is no intersection.
# camera and P_plane are 3D points, direction and N_plane (normal) are normalized vectors.
denom = np.dot(direction, N_plane)
if np.abs(denom) < 1e-6:
return np.inf
d = np.dot(P_plane - camera, N_plane) / denom
if d < 0:
return np.inf
return d
def intersect_sphere(camera, direction, C_sphere, R_sphere):
"""<li>camera is our camera (<i>Its a point</i>)</li>
<li>direction is the intersection point of the ray and sphere</li>
<li>C_sphere is center of sphere</li>
<li>R_sphere is is the radius of the sphere</li>
This function gives us 2 points of intersection of the ray with our sphere
"""
# Return the distance from O to the intersection of the ray (camera, direction) with the
# sphere (C_sphere, R_sphere), or +inf if there is no intersection.
# O and S are 3D points, direction (direction) is a normalized vector, R is a scalar.
radius2 = R_sphere * R_sphere
l = C_sphere - camera
tca = np.dot(l , direction)
if tca>0:
distance2 = np.dot(l,l) - tca * tca
if radius2<distance2:
return np.inf
thc = np.sqrt(radius2-distance2)
t0 , t1 = min(tca-thc , tca + thc) , max(tca-thc , tca + thc)
if t1 >= 0 :
return t1 if t0 < 0 else t0
return np.inf
def intersect(camera, ray_dir, obj):
"""A function to segregate between intersection of the ray with our plane or with our image"""
if obj['type'] == 'plane':
return intersect_plane(camera, ray_dir, obj['position'], obj['normal'])
elif obj['type'] == 'sphere':
return intersect_sphere(camera, ray_dir, obj['position'], obj['radius'])
def get_normal(obj, intrsct):
"""We love playing with normals here, hence this function is just for finding the unit normal of any given object.<br> We first check for the type of object from the object dictionary.
<li>Since we stored the normal of the plane with the object itself, we can straight away give the normal to the plane.</li>
<li>For sphere we normalise the ray from M to the center of our sphere , which then becomes our normal.
</li>
<li>intrsct here is intrsct = rayO + rayD * t , the equation we use to traverse across the intersection points of our objects with the rays
</li>
<li>N returned here is the unit normal of that respective object</li>
"""
# Find normal.
if obj['type'] == 'sphere':
N = normalize(intrsct - obj['position'])
elif obj['type'] == 'plane':
N = obj['normal']
return N
def get_color(obj, intrsct):
"""This function is used for choosing the color and making any changes to it whatsoever required. Based on the object , we assign it the colour.
<li>intrsct here is intrsct = rayO + rayD * t , the equation we use to traverse across the intersection points of our objects with the rays.
</li>
<li> Color returned is the color of the object in question , and this is what we will paint it with . </li>
"""
color = obj['color']
if not hasattr(color, '__len__'):
color = color(intrsct)
return color
def trace_ray(rayOrigin, rayDirection):
"""<h3>The function arguments</h3>
<li>rayO is the ray origin . This will be the starting point of our ray from which we will make the ray</li>
<li>rayD is the ray direction. From the ray origin, the ray will pass through the center of the pixel that we will be looking at. The ray in this direction will be labelled as rayD</li>
<h3>Find the point of intersection with the scene</h3>
<li>The enumerate function is used to go through the entire scene, it adds a counter to it's function arguments and hence ,we can iterate through the entire scene in one loop. </li>
<li>We supply ray origin and raydir and find out the intersection point , and then store them in t_obj. Since we intialised t to infinity, if we get any intersection point , we will store it along with the index to remember it in the future.</li>
<li>Once we exit the loop , we check if t is still infinity. If it is , that means there are no intersection points and hence we return null value , or the background color.</li>
<li>The line "obj = scene[obj_idx]" means that we take the object. the object index will tell us which one it is , in this case , whether its sphere#1,#2,#3 or the plane. We store this value in obj.
<li>To traverse through the intersection point, we start at ray origin, and then traverse t distance ( t is the intersection point ) in ray dir. Its a scalar*vector multiplication .
<li>Then we get the properties of object , namely , it's normal to the intersection point and it's color.
<li>shadow_raydir and toO are our normalised vectors. shadow_raydir is the vector from M to L (<i>L is our light source ki position)</i>. toO is the vector from M to O <i>(O is the position of our camera)</i>
<h3>Game of Shadows</h3>
<li>This time , we start moving from M. This will be our reflected ray , so this time our ray origin becomes M. However , to avoid code being messy and all , we carry on with M as our new ray origin.
<li> Since N is the normal , this is our new ray dir. Why is it so? Coz after light hits a point , it is always going to be reflected in the direction of the normal from the center to the point of intersection, the 90degree angle change will happen. We are ignoring diffused reflection here , only normal reflection is being considered.
<li>Again we iterate through all the elements of the scene and check for their intersection. If there <i>are no intersections</i> , then we can <i>light</i> the pixel with suitable color. However, if there <i>are one or more intersections </i> , then <i>we cast a shadow on it </i>.
<li>Coloring time....yaay
<br>Default light and material parameters.<br>
       ambient = .05<br>
       diffuse_coeff = 1.<br>
       specular_coeff = 1.<br>
       specular_k = 50<br>
<li> Ambient variable sets the colour of the ray of light that is going to pass from our light source.
<li>diffuse_coeff is the variable that comes into picture for diffuse coloring. This is because when a light hits an object , it cant be lighted the same way for all the points of the object , as different light rays will hit them. Hence this is used for changing the diffusing accordingly.
<li>specular colouring is used for showing the light where intensity is so high that it is seen by us as the source of the light itself.
"""
# Find first point of intersection with the scene.
t = np.inf
for i, obj in enumerate(scene):
t_obj = intersect(rayOrigin, rayDirection, obj)
if t_obj < t:
t, obj_idx = t_obj, i
# Return None if the ray does not intersect any object.
if t == np.inf:
return
# Find the object.
obj = scene[obj_idx]
# Find the point of intersection on the object.
intrsct = rayOrigin + rayDirection * t #traverse to the intersection points
# Find properties of the object.
N = get_normal(obj, intrsct)
color = get_color(obj, intrsct)
shadow_raydir = normalize(Light_postion - intrsct)
toO = normalize(camera - intrsct)
# Shadow: find if the point is shadowed or not.
l = [intersect(intrsct + N * .0001, shadow_raydir, obj_sh)
for k, obj_sh in enumerate(scene) if k != obj_idx]
if l and min(l) < np.inf:
return
# Start computing the color.
col_ray = ambient
# Lambert shading (diffuse).
col_ray += obj.get('diffuse_coeff', diffuse_coeff) * max(np.dot(N, shadow_raydir), 0) * color
# Blinn-Phong shading (specular).
col_ray += obj.get('specular_coeff', specular_coeff) * max(np.dot(N, normalize(shadow_raydir + toO)), 0) ** specular_k * color_light
return obj, intrsct, N, col_ray
def add_sphere(position, radius, color):
"""The add_sphere function adds a sphere as a dictionary along with its position , radius , color and reflectivity<br>
The parameters of each sphere are :
<li>Type is sphere</li>
<li>Position <i>(maybe the centre, an assumption of Param)</i></li>
<li>Radius of the sphere</li>
<li>Color of the sphere</li>
<li>Reflection by the sphere </li>
"""
return dict(type='sphere', position=np.array(position),
radius=np.array(radius), color=np.array(color), reflection=.5)
def add_plane(position, normal):
"""add_plane function will add our chess type plane here. the color is chosen on the basis of even odd from the starting , rest all values are set at a default
<br>The parameters of the plane are :
<li> A type plane </li>
<li> A position </li>
<li> A normal </li>
<li> The colour , as intermitting black and white squares </li>
<li> Diffuse_color </li>
<li> Specular_color</li>
<li> Reflection</li>
"""
return dict(type='plane', position=np.array(position),
normal=np.array(normal),
color=lambda M: (color_plane_white
if (int(M[0] * 2) % 2) == (int(M[2] * 2) % 2) else color_plane_black),
diffuse_coeff=.75, specular_coeff=.5, reflection=.25)
"""We start out by defining our objects. color_plane_white and color_plane_black are 3x1 matrices consisting of 1 and 0 respectively. We then add 3 spheres and a ground to our scene."""
# List of objects.
color_plane_white = 1. * np.ones(3)
color_plane_black = 0. * np.ones(3)
scene = [add_sphere([.75, .1, 1.], .6, [0., 0., 1.]),
add_sphere([-.75, .1, 2.25], .6, [.5, .223, .5]),
add_sphere([-2.75, .1, 3.5], .6, [1., .572, .184]),
add_plane([0., -.5, 0.], [0., 1., 0.]),
]
"""The scene includes all of our objects that we are going to draw . Currently , includes 3 spheres and 1 plane. The plane has individual black and white squares , like a chess board . """
"""
The default parameters are intialised accordingly.
<ol>All are initialised to x,y,z coordinates here:
"""
# Light position and color.
Light_postion = np.array([5., 5., -10.])
"""We start of with light positioned at (5,5,-10). Z-coordinate is negative , hence the light point is <u>behind us into the screen</u>."""
color_light = np.ones(3)
""" The color is initalised to (1,1,1) which is white. <br>"""
# Default light and material parameters.
ambient = .05
diffuse_coeff = 1.
specular_coeff = 1.
specular_k = 50
depth_max = 5 # Maximum number of light reflections.
"""Depth max tells us the number of light reflections that we want to allow. """
color = np.zeros(3) # Current color.
camera = np.array([0., 0.35, -1.]) # Camera.
"""<li> O is our camera . </li>
"""
Q = np.array([0., 0., 0.]) # Camera pointing to.
""" <li> Q is the pixel where our camera is pointing at.</li>
"""
img = np.zeros((height, width, 3)) # Image window
""" <li>We initialise imgage of shape height*width here , and each pixel will be a 3d array consisting of all 3 coordiantes.</li>
"""
def main(color):
"""<li>Aspect ratio : 'r' is our aspect ratio .
<li> We start off from screen coordinates x0,y0,x1,y1 and traverse through the entire screen.
<li>np.linspace is used for iterating through a lot of values.<i>(For more information , go : <a href="https://www.geeksforgeeks.org/numpy-linspace-python/"> here</a>)</i>. Speaking in layman terms, we iterate through the entire scene, the first loop is for going along width and the nested loop is for going along the height.
<li>we initialise the entire column arrays to 0 and the first 2 elements of the Q array to x and y co-ordinates.
<li>D is the unit vector from Q to 0.
<li> We are starting with depth 0. The more depth we can keep , the better our resulting image will be. However , it is computationally heavy , so we have kept it at depth 5.
<li> We set the ray origin to be the graphical origin (0,0,0) and ray direction to be the respective x and y of the particular pixel we are investigating.
<li> We have kept reflection to 1 , which means that reflection is there.
<li> Our function trace_ray returns the object, its intersection point with the current ray , the normal from the center (<i>position</i>) of our object to the point and the color of the particular pixel.<b><br>If it does not return anything, it means there is no pixel to be coloured , and hence should be same as the background scene </b>
<li>Once traced the ray , we assign those values from the traced data type into our variables here.
<li> Now we go towards reflection. Just like in the reflection , we take ray origin and ray direction as M+N*xxx and unit vector in the normal directio respectively.
<li> We multiply and add the color multiplied with reflection to get a different color accordingly as per the lighting required. Then we store this value in reflection variable for future use.
<li> Finally after all the loops are over , we store the information gathered in image , height-width wise from the col array so that we can later print it out on the screen, or save it as required.
"""
r = float(width) / height #we get the aspect ratio as 'r'
# Screen coordinates: x0, y0, x1, y1.
screen_coordinates = (-1., -1. / r + .25, 1., 1. / r + .25)
# Loop through all pixels.
for i, x in enumerate(np.linspace(screen_coordinates[0], screen_coordinates[2], width)):
for j, y in enumerate(np.linspace(screen_coordinates[1], screen_coordinates[3], height)):
color[:] = 0
Q[:2] = (x, y)
D = normalize(Q - camera)
depth = 0
rayO, rayD = camera, D
reflection = 1.
# Loop through initial and secondary rays.
while depth < depth_max:
traced = trace_ray(rayO, rayD)
if not traced:
break
obj, M, N, col_ray = traced
# Reflection: create a new ray.
rayO, rayD = M + N * .0001, normalize(rayD - 2 * np.dot(rayD, N) * N)
depth += 1
color += reflection * col_ray
reflection *= obj.get('reflection', 1.)
img[height - j - 1, i, :] = np.clip(color, 0, 1)
if __name__ == "__main__":
"""Finally showing the image using the image show function of matlplotlib"""
main(color)
plt.imshow(img)