-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.py
359 lines (289 loc) · 14 KB
/
main.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
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
from time import time
# ^^computationally intensive code so benchmarking execution time (alternative module with more granular insights: cProfile)
import math
import numpy as np
import matplotlib.pyplot as plt
#################### coordinates
# DISPLAY PLANE: transform from display pixels:
# i = 0 : ncolumns-1, j = 0 : nrows-1
# to NDC = normalized 'device' coords:
# x = -1 : 1, y = -nrows/ncolumns : nrows/ncolumns
def xd_from_i(i, ncolumns):
'''
scale pixel indices along display width to xd in [-1, 1]
--> set scene elements (positions and sizes) relative to this scaled 'x' so the FOV will contain the same scene even as the display size changes
'''
return (i - (ncolumns/2) + (1/2)) * (2/ncolumns)
def yd_from_j(j, nrows, ncolumns):
'''
scale by same factor as xd (since pixels are square, even if display isn't), so end up with yd in [-nrows/ncolumns, nrows/ncolumns] (nb: not in [-1, 1])
'''
return ((nrows/2) - j - (1/2)) * (2/ncolumns)
def convert_f_to_zd(ncolumns):
'''
convert the camera focal distance to the display plane z-coordinate per display x scaling
'''
return -f * 2/ncolumns
##################### camera helper functions
# field of view
def fov_degrees(focal_distance, frame_dimension):
return (180/(2*pi))*2*atan((frame_dimension/2)/focal_distance)
# focal depth
# bokah
#################### camera generator
class Camera():
def __init__(self, center = [0, 0, 0], azimuth = 0):
'''
generate instances of Camera: defined by its 'sensor' position (center) + any view tilt (rotational angles)
'''
self.center = np.array(center)
self.azimuth = azimuth
#################### ray generator
class Ray():
def __init__(self, start_point, direction):
'''
generate instances of Ray: defined by a starting position and direction (direction doesn't need to be unit length)
'''
self.start_point = np.array(start_point)
self.direction = np.array(direction)
#################### scene element generators
class Sphere():
def __init__(self, center, radius, color, k_diffuse = 1, k_refl = 0.5, k_refr = 0.5, n = 1.5):
'''
generate instances of Sphere: defined by constant radius (surface - center distance)
'''
self.center = np.array(center)
self.radius = radius
self.color = np.array(color)
self.diffuse = k_diffuse
self.refl = k_refl
self.refr = k_refr
self.index = n
def __str__(self):
return 'Sphere object: center = ' + str(self.center) + ', radius = ' + str(self.radius)
class Plane():
def __init__(self, point_on, normal, color, k_diffuse = 1, k_refl = 0.5, k_refr = 0.5, n = 1):
'''
generate instances of Plane: defined by a normal that's perpendicular to any on-plane vector, which can be established by any two points on the plane
'''
self.point_on = np.array(point_on)
self.normal = np.array(normal)
self.color = np.array(color)
self.diffuse = k_diffuse
self.refl = k_refl
self.refr = k_refr
self.index = n
def __str__(self):
return 'Plane object: point on plane = ' + str(self.point_on) + ', normal = ' + str(self.normal)
#################### light generator
class Light():
def __init__(self, center, color):
'''
generate instance of Light: *emits* light, so color is active, not reactive
'''
self.center = np.array(center)
self.color = np.array(color)
def __str__(self):
return 'Light object: position = ' + str(self.center) + ', color = ' + str(self.color)
# vector helper functions
# dot product, or if numpy: use np.dot(va, vb)
def dotvecs(vec_a, vec_b):
dotvecs = 0
for i in vec_a:
for j in vec_b:
dotvecs += i*j
return docvecs
# unit-length vector - needs numpy module but could adapt to use dotvecs()
def vec_unit(vec):
return np.array(vec)/np.linalg.norm(vec)
# surface normals: plane.normal attribute established by Plane.__init__
def surface_normal(hit_point, hit_element):
if isinstance(hit_element, Plane):
return hit_element.normal
if isinstance(hit_element, Sphere):
return normal_to_sphere(hit_point, hit_element.center)
def normal_to_sphere(point_on_sphere, sphere_center):
'''
the normal computed points outward
'''
return (np.array(point_on_sphere) - np.array(sphere_center))/np.linalg.norm(point_on_sphere - sphere_center)
#################### ray - scene element intersections
# ray-point intersection
def intersect_point(ray, point):
'''
returns t for point = ray.start + (t * ray.direction)
'''
t = []
#print(len(point), point[0], ray.start_point[0])
for i in range(len(point)):
t.append((point[i] - ray.start_point[i])/ray.direction[i])
if i > 0:
if t[i] != t[i-1]:
return np.inf
return t[0]
# ray-sphere intersection
def discriminant(a, b, c):
'''
if the discriminant is < 0, the solution to the quadratic formula is complex -> no intersection -> t = np.inf
'''
return (b**2) - (4*a*c)
def t_sphere(a, b, c):
'''
solution to the quadratic formula:
nb: the nearest intersection is the lower 't' value
nb: if t < 0, the intersection is 'behind' the ray start point: treat as no intersection: t = np.inf
'''
return (-b - (discriminant(a, b, c)**0.5))/(2*a) if (-b - (discriminant(a, b, c)**0.5))/(2*a) > 0 else np.inf
def intersect_sphere(ray, sphere):
'''
return ray parameter t for nearest intersection with a sphere
'''
a = np.dot(ray.direction, ray.direction)
ray_start_to_sphere_center = sphere.center - ray.start_point
b = -2*np.dot(ray_start_to_sphere_center, ray.direction)
c = np.dot(ray_start_to_sphere_center, ray_start_to_sphere_center) - (sphere.radius**2)
d = discriminant(a, b, c)
if d >= 0: # negative discriminant implies solution lies in complex plane
return t_sphere(a, b, c)
else:
return np.inf
# ray-plane intersection
def intersect_plane(ray, plane):
'''
return ray parameter t for nearest intersection with a plane
'''
ray_dot_normal = np.dot(ray.direction, plane.normal)
if abs(ray_dot_normal) > .0001: # if smaller, ray is nearly parallel to plane
t_new = np.dot(plane.point_on - ray.start_point, plane.normal)/ray_dot_normal
if t_new < 0: # intersection 'before' ray origin -> no in-scene intersection -> np.inf
return np.inf
return t_new
else:
return np.inf # treat ray as || to plane -> no visible intersection in scene
# ray-scene: nearest intersections
def find_nearest(ray, scene, hit_element = 0):
'''
cast ray and return the ray parameter t and Cartesian location of the nearest intersection plus the intersected object
'''
t = np.inf
for element in scene:
if isinstance(element, Light):
t_new = intersect_point(ray, element.center)
elif isinstance(element, Sphere):
t_new = intersect_sphere(ray, element)
elif isinstance(element, Plane):
t_new = intersect_plane(ray, element)
if t_new < t: # => iteratively reassign t to the most recent lowest t_new
t = t_new
hit_element = element
return t, ray.start_point + (t * np.array(ray.direction)), hit_element
#################### color/shading
def shift_ray_start(start_point, normal, delta = 0):
'''
ray origin shifted slightly away from surface: the 'delta' fudge factor compensates for computational inaccuracy that may inadvertently locate the hit_point slightly off to the wrong* side of the hit_object surface; this matters for subsequent computation of next intersection for rays starting at hit_point
* 'wrong' per surface normal direction
'''
if not delta:
delta = .0001
return start_point + delta*normal
# ambient light: constant scaling factor k_ambient
def ambient_contrib(hit_element):
'''
generally, every scene has some level of evenly distributed ambient illumination: pitch black dark is rare
'''
return k_ambient * hit_element.color
# diffusely scattered light contributions from non-occluded lights
def diffuse_contrib(hit_point, hit_element):
'''
compute the additive color contribution from each light source as diffusely scattered at each intersection (hit) point with a direct line of sight to the light
'''
diffuse_color = np.array([0.0, 0.0, 0.0])
k_shift = .0001
hit_surf_normal = surface_normal(hit_point, hit_element)
ray_start = shift_ray_start(hit_point, hit_surf_normal, k_shift)
for light in lights:
dir_to_light = light.center - ray_start
ray_to_light = Ray(ray_start, dir_to_light)
t_to_light = 1
new_t, new_hit_point, new_hit_element = find_nearest(ray_to_light, scene)
if new_t >= t_to_light: #not occluded
diffuse_color += (((light.color) + hit_element.color)/2) * lambertian_factor(dir_to_light, hit_surf_normal) * hit_element.diffuse
return diffuse_color
def lambertian_factor(dir_to_light, surface_normal):
unit_dir_to_light = vec_unit(dir_to_light)
unit_surface_normal = vec_unit(surface_normal)
return np.dot(unit_dir_to_light, unit_surface_normal) if np.dot(unit_dir_to_light, unit_surface_normal) >= 0 else 0
# fresnel governed contributions from reflected and refracted rays traced back from the primary ray to a light source or to background (no object or light intersected) or to a total internal reflection point or to a maximum number of recursions (depth)
def fresnel_contrib(t, start_point, direction, start_element, depth = 2):
'''
recursively trace rays' reflections and refractions, compounding the rays' fresnel-governed contributions to the the primary ray; depth limits the number of recursions - an alternative would be to halt recursion once the compounded contribution is less than a given minimum
nb: as for diffuse contributions, incident ray color (intensity) should be scaled by cos angle of incidence to surface normal - NOT YET ADDED
'''
if type(hit_element) == int or t == np.inf: # or TIR
return np.array([0.0, 0.0, 0.0])
elif start_element in lights or depth == 0:
return start_element.color # ideally, this would be scaled inversely with distance
else:
depth -= 1
surf_normal = surface_normal(start_point, start_element)
start_point_shifted = shift_ray_start(start_point, surf_normal)
refl_next_direction = 2*np.dot(direction, surf_normal)*surf_normal # angle incidence = angle reflection
refr_next_direction = direction # NOT ACCURATE! NEEDS TO REFLECT SNELL'S LAW
refl_ray = Ray(start_point_shifted, refl_next_direction)
refr_ray = Ray(start_point_shifted, refr_next_direction)
t_refl, refl_next_hit_point, refl_next_hit_element = find_nearest(refl_ray, scene + lights)
t_refr, refr_next_hit_point, refr_next_hit_element = find_nearest(refr_ray, scene + lights)
if t_refr: # surf_normal > 0: FIX THIS - check the direction of the surface normal
n_from = 1 # from 'outside' to 'inside' so this is air
n_to = start_element.index
else:
n_from = start_element.index
n_to = 1 # air
k_refl = 0.5 # FUDGE FACTOR - need to compute Fresnel reflection coefficient
k_refr = 0.5 # ^^^DITTO
return start_element.color * ((start_element.refl*fresnel_contrib(t_refl, refl_next_hit_point, refl_next_direction, refl_next_hit_element, depth)) + (start_element.refr*fresnel_contrib(t_refr, refr_next_hit_point, refr_next_direction, refr_next_hit_element, depth)))
#################### main
if __name__ == "__main__":
start = time()
# TO ADD: read in scene parameters from file
# Display plane
display_scale = 5
display_width = 16 * display_scale
display_height = 9 * display_scale
fov_scale = 1 # fov inversely proportional to fov_scale
f = (display_width/2) / fov_scale
zd = convert_f_to_zd(display_width)
# Camera
c = Camera([0, 0, 0])
# Scene: generate spheres, planes, light sources
# units (!): display coords are scaled to x in [-1, 1] with aspect ratio maintained (y scaled by nrows/ncolumns); to maintain FOV, z scales with display width (this choice assumes display width >= display height so will have a greater impact on potential edge distortions)
small_radius = 0.2
large_radius = 20
spheres = [Sphere([j*fov_scale/10, abs(j/2)/10, -1-(abs(j)/10)], small_radius, [.1, 0, .1], 0, .5, .5, 1.5) for j in range(-30, 35, 5)]
spheres.append(Sphere([0, -large_radius+(.5*small_radius), -4.5], large_radius, [.2, .35, .3], 0.4))
planes = [Plane([0, -small_radius, 0], [0, 1, 0], [.01, .05, .1], .5, .5, .5, 1.3)]
lights = [Light([-.25, small_radius/4, -.5], [1, 1, 1]), Light([1.5, 2 + small_radius, -2], [.8, .8, .8])]
k_ambient = 0.1
background_color = np.array([0.1, 0.1, 0.1])
scene = spheres + planes
# set up array to store pixel colors: row_index, col_index, [r, g, b]
img = np.zeros((display_height, display_width, 3))
# core logic
for j in range(display_height):
yd = yd_from_j(j, display_height, display_width)
for i in range(display_width):
xd = xd_from_i(i, display_width)
pixel_ray = Ray(c.center, [xd, yd, zd])
t, hit_point, hit_element = find_nearest(pixel_ray, scene + lights)
if hit_element != 0:# hit_element = 0 means no intersection found
c_ambient = ambient_contrib(hit_element)
c_diffuse = diffuse_contrib(hit_point, hit_element)
c_fresnel = fresnel_contrib(t, hit_point, pixel_ray.direction, hit_element, 5)
color_xy = c_ambient + c_diffuse + c_fresnel
else:
color_xy = background_color
img[j, i, :] = color_xy
image_filename = 'raytray_image.png'
plt.imsave(image_filename, img)
end = time()
print(f'Image saved as {image_filename}\nDisplay: {display_width} x {display_height}\nTime to compute: {end - start} s')