使用Python带你从0开始构建光追算法
光线追踪算法(Python)
前置要求
-
基本向量运算:点积,取模,单位化
-
求解二元方程
-
耐心与想象力
光线追踪算法
实际上,光线追踪是一种渲染技术,它模拟光线的路径和与物体的交互,能够生成高度真实感的图像。这种算法的更优化变体实际上被用于视频游戏中!
要解释这个算法,我们需要设置一个场景:
-
我们需要一个三维空间(这意味着我们将使用三个坐标来定位空间中的物体)
-
我们需要空间中的物体(这里我们使用几个球体)
-
我们需要一个光源(这将是一个向所有方向发光的单一点,本质上是一个位置)
-
我们需要一个“眼睛”或相机来观察场景(也仅仅是一个位置)
-
由于相机可能真的在任何地方观察,我们需要一个屏幕,通过这个屏幕相机将观察物体(矩形屏幕四个角的四个位置)。
屏幕是一个网格平面,在每个网格我们都假设有一束光通过(由相机发出),这一个个“光束”是我们算法计算的最小单位。光束的方向可以由“相机”和“屏幕上的像素点”唯一确定,而“相机”位置固定,因此我们可以仅使用“屏幕像素点位置”确定光束。
在此场景下我们的光追算法可以有如下表示:
对于屏幕上的每个像素 p(x,y,z):
将黑色与 p 关联
如果从相机出发并指向 p 的光线(直线)与场景中的任何物体相交,则:
计算与最近物体的交点
如果在交点和光源之间没有场景中的其他物体,则:
计算交点的颜色
将交点的颜色与 p 关联
请注意,这个过程实际上是现实照明的反向过程。在现实中,光从光源发出,向所有方向扩散,反射在物体上,然后进入你的眼睛。然而,由于不是所有从光源发出的光线都会最终进入你的眼睛,光线追踪采用了反向过程来节省计算时间(从眼睛追踪光线返回到光源)。
这完全是几何学的问题,唯一没有解释的是如何计算交点的颜色。现在只需知道存在相应的物理模型,这些模型描述了当光线以某个角度、强度等撞击它们时,物体是如何被照亮的。
在算法的最后,我们将用正确的颜色填充屏幕,然后可以将其保存为图像。
场景设置
在开始编码之前,我们需要设置一个场景。目前,我们将决定相机和屏幕的位置。为了简化问题,我们将它们与单位轴对齐
import numpy as np
import matplotlib.pyplot as plt
width = 300
height = 200
camera = np.array([0, 0, 1])
ratio = float(width) / height
screen = (-1, 1 / ratio, 1, -1 / ratio) # left, top, right, bottom
image = np.zeros((height, width, 3))
for i, y in enumerate(np.linspace(screen[1], screen[3], height)):
for j, x in enumerate(np.linspace(screen[0], screen[2], width)):
# image[i, j] = ...
print("progress: %d/%d" % (i + 1, height))
plt.imsave('image.png', image) #现在的输出效果是一张纯黑的图片
这里我们只是将屏幕转换为宽度为1的固定大小,长宽比例保持不变。宽度和高度的数值大小决定了像素密度。
光线交互
光线的定义
既然射线从摄像机开始并沿着当前目标像素的方向前进,我们就可以定义一个指向类似方向的单位向量。因此,我们将 "从摄像机出发并朝像素方向前进的射线 "定义为下式:
(1)
摄像机和像素都是 3D 点。当 t=0 时,到达摄像机的位置,t 越大,在像素方向上离摄像机越远。这是一个参数方程,在给定 t 的情况下,可以得到沿线上的一个点。当然,摄像机或像素并没有什么特别之处,我们同样可以将一条从原点(O)出发并向终点(D)延伸的射线定义为:
(2)
为方便起见,我们将 d 定义为方向向量。现在我们可以完成代码并添加射线的计算。
import numpy as np
import matplotlib.pyplot as plt
def normalize(vector):
return vector / np.linalg.norm(vector)
width = 300
height = 200
camera = np.array([0, 0, 1])
ratio = float(width) / height
screen = (-1, 1 / ratio, 1, -1 / ratio) # left, top, right, bottom
image = np.zeros((height, width, 3))
for i, y in enumerate(np.linspace(screen[1], screen[3], height)):
for j, x in enumerate(np.linspace(screen[0], screen[2], width)):
pixel = np.array([x, y, 0])
origin = camera
direction = normalize(pixel - origin)
# image[i, j] = ...
print("progress: %d/%d" % (i + 1, height))
plt.imsave('image.png', image)
-
我们添加了 normalize(vector) 函数,它返回一个归一化向量
-
我们增加了对原点和方向的计算,它们共同定义了一条射线。请注意,像素的 z=0,因为它位于屏幕上,而屏幕包含在 x 轴和 y 轴形成的平面内
现在我们进入第二部分,即与场景中的任何对象相交。这是略微 "困难 "的部分。对于我们要处理的每种类型的物体(球体、平面、三角形等),计算方法都是不同的。为了简单起见,我们将只渲染球体。因此下一部分我们将了解
-
如何定义球面
-
如果存在射线与球面的交点,我们如何计算交点
球体定义
球面实际上是一个非常简单的数学对象。球面的定义是:与给定点(圆心)距离 r(半径)相同的点的集合。因此,给定球心 C 及其半径 r,当且仅当以下条件时,任意点 X 位于球面上:
(3)
我们可以在屏幕声明后定义一些球体。
objects = [
{ 'center': np.array([-0.2, 0, -1]), 'radius': 0.7 },
{ 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1 },
{ 'center': np.array([-0.3, 0, 0]), 'radius': 0.15 }
]
球体交互
我们知道射线方程,也知道一个点必须满足什么条件才能位于球面上。我们要做的就是将公式 2 与公式 3联立求解t。等价描述:在哪个 t 条件下,射线会位于球面上?
我们把与 t²、t¹、t⁰ 相关的系数分别称为 a、b 和 c。让我们计算一下这个方程的判别式:
由于 d(方向)是单位向量,所以 a=1 。一旦我们计算出该方程的判别式,就有 3 种可能:
我们只使用第三种情况来检测交点。下面这个函数可以检测射线与球面的交点。如果射线确实与球面相交,它将返回从射线原点到最近交点的距离 t,否则返回 None。
def sphere_intersect(center, radius, ray_origin, ray_direction):
b = 2 * np.dot(ray_direction, ray_origin - center)
c = np.linalg.norm(ray_origin - center) ** 2 - radius ** 2
delta = b ** 2 - 4 * c
if delta > 0:
t1 = (-b + np.sqrt(delta)) / 2
t2 = (-b - np.sqrt(delta)) / 2
if t1 > 0 and t2 > 0:
return min(t1, t2)
return None
请注意,只有当 t1 和 t2 都为正数时,我们才会返回最近的交点(因为有 2 个交点)。这是因为解方程的 t 可以是负值,但这意味着与球面相交的射线的方向向量不是 d,而是 -d(例如,如果球面位于摄像机和屏幕的后面)。
最近相交对象
射线可能与多个物体相交,我们只需要找到最近相交的物体,因为后面的物体都会被挡住
我们可以很容易地创建一个函数,使用 sphere_intersect()来查找射线与之相交的最近物体(如果存在的话)。我们只需循环遍历所有球体,搜索相交处,然后保留最近的球体。
def nearest_intersected_object(objects, ray_origin, ray_direction):
distances = [sphere_intersect(obj['center'], obj['radius'], ray_origin, ray_direction) for obj in objects]
nearest_object = None
min_distance = np.inf
for index, distance in enumerate(distances):
if distance and distance < min_distance:
min_distance = distance
nearest_object = objects[index]
return nearest_object, min_distance
调用该函数时,如果 nearest_object 为 None,则表示没有物体与射线相交;否则,其值就是最近的相交物体,我们将得到 min_distance,即从射线原点到交点的距离。
相交点
为了计算交点,我们使用了前面的函数:
nearest_object, distance = nearest_intersected_object(objects, o, d)
if nearest_object:
intersection_point = o + d * distance #这里的distance就是前面的参数t
下面是我们目前的代码:
import numpy as np
import matplotlib.pyplot as plt
def normalize(vector):
return vector / np.linalg.norm(vector)
def sphere_intersect(center, radius, ray_origin, ray_direction):
b = 2 * np.dot(ray_direction, ray_origin - center)
c = np.linalg.norm(ray_origin - center) ** 2 - radius ** 2
delta = b ** 2 - 4 * c
if delta > 0:
t1 = (-b + np.sqrt(delta)) / 2
t2 = (-b - np.sqrt(delta)) / 2
if t1 > 0 and t2 > 0:
return min(t1, t2)
return None
def nearest_intersected_object(objects, ray_origin, ray_direction):
distances = [sphere_intersect(obj['center'], obj['radius'], ray_origin, ray_direction) for obj in objects]
nearest_object = None
min_distance = np.inf
for index, distance in enumerate(distances):
if distance and distance < min_distance:
min_distance = distance
nearest_object = objects[index]
return nearest_object, min_distance
width = 300
height = 200
camera = np.array([0, 0, 1])
ratio = float(width) / height
screen = (-1, 1 / ratio, 1, -1 / ratio) # left, top, right, bottom
objects = [
{ 'center': np.array([-0.2, 0, -1]), 'radius': 0.7 },
{ 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1 },
{ 'center': np.array([-0.3, 0, 0]), 'radius': 0.15 }
]
image = np.zeros((height, width, 3))
for i, y in enumerate(np.linspace(screen[1], screen[3], height)):
for j, x in enumerate(np.linspace(screen[0], screen[2], width)):
pixel = np.array([x, y, 0])
origin = camera
direction = normalize(pixel - origin)
# check for intersections
nearest_object, min_distance = nearest_intersected_object(objects, origin, direction)
if nearest_object is None:
continue
# compute intersection point between ray and nearest object
intersection = origin + min_distance * direction
# image[i, j] = ...
光线交点
到目前为止,我们知道是否存在一条从摄像机/眼睛到物体的直线,我们也知道这是哪个物体,以及我们正在观察的物体的确切部分。我们还不知道的是,那个特定的点是否被照亮了!也许光线并没有照射到那个特定的点上,所以我们没有必要继续前进,因为我们看不到它。因此,下一步是检查在交点和光线之间是否没有任何物体,确保我们找到的交点可以被光源发出的光照射到。
幸运的是,我们已经有了一个函数:nearest_intersected_object()。事实上,我们想知道从交点开始向灯光方向延伸的射线在穿过灯光之前是否与场景中的某个物体相交。这实际上与之前的任务相同,我们只需要改变光线的原点和方向。首先,我们需要定义光线。可以在对象声明附近添加:
light = { 'position': np.array([5, 5, 5]) }
要检查一个物体是否对交点产生了阴影,我们必须穿过从交点开始并朝向光源的射线,看看返回的最近物体是否比光源更接近交点(换句话说,在两者之间)。
intersection = origin + min_distance * direction
intersection_to_light = normalize(light['position'] - intersection)
_, min_distance = nearest_intersected_object(objects, intersection, intersection_to_light)
intersection_to_light_distance = np.linalg.norm(light['position'] - intersection)
is_shadowed = min_distance < intersection_to_light_distance
看起来很简洁,但这是行不通的......我们需要稍作调整。如果我们将交点作为新射线的原点,那么我们可能会将我们现在所在的球体检测为交点和光线之间的物体(intersection在某个objects的表面上,min_distance直接等于0)。要快速解决这个问题,一个广泛使用的方法就是迈出一小步,让我们远离球面。我们通常使用球面的法向量,然后朝这个方向迈出一小步。
这种技巧不仅适用于球体,也适用于任何物体。
因此,正确的代码是
intersection = origin + min_distance * direction
normal_to_surface = normalize(intersection - nearest_object['center']) # 表面法向量
shifted_point = intersection + 1e-5 * normal_to_surface # 离开表面一个很小的距离
intersection_to_light = normalize(light['position'] - shifted_point)
_, min_distance = nearest_intersected_object(objects, shifted_point, intersection_to_light) # min_distance默认inf
intersection_to_light_distance = np.linalg.norm(light['position'] - intersection)
is_shadowed = min_distance < intersection_to_light_distance
if is_shadowed:
continue
Blinn-Phong反射模型
Blinn-Phong反射模型是一种用于计算物体表面光照效果的计算模型,常用于计算机图形学中来模拟光与物体表面的交互。这个模型是由Jim Blinn基于Phong反射模型进行改进的。其核心思想是用来更加有效地近似光滑表面上光的反射行为,特别是高光(specular highlights)的表现。
Blinn-Phong模型主要包含以下几个部分:
-
环境光(Ambient Lighting):模拟来自环境中各个方向均匀散射的光,通常用一个常数来表示,表示物体即使在没有直接光照的情况下也会有一定的亮度。
-
漫反射光(Diffuse Lighting):当光线打到一个粗糙的表面时,光线会在多个方向上散射。漫反射光的强度依赖于光源的方向和表面的法线之间的角度。当这两者正交时,漫反射达到最大。
-
高光反射(Specular Lighting):光线打到光滑表面时,会产生明显的高光。在Blinn-Phong模型中,这种高光的计算使用了一个中间向量,即光源方向和视线方向的半角向量。这个半角向量与表面法线的角度越小,高光越亮。
Blinn-Phong模型的优点在于计算效率高,非常适合实时渲染系统,如视频游戏中。它的缺点是物理上不够精确,特别是在模拟复杂材质和光照条件时,可能不如基于物理的渲染模型(PBR)那么真实。
因此,场景中的每个物体都必须具备这 4 个属性。我们把它们添加到球体中。
objects = [
{ 'center': np.array([-0.2, 0, -1]), 'radius': 0.7, 'ambient': np.array([0.1, 0, 0]), 'diffuse': np.array([0.7, 0, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100 },
{ 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1, 'ambient': np.array([0.1, 0, 0.1]), 'diffuse': np.array([0.7, 0, 0.7]), 'specular': np.array([1, 1, 1]), 'shininess': 100 },
{ 'center': np.array([-0.3, 0, 0]), 'radius': 0.15, 'ambient': np.array([0, 0.1, 0]), 'diffuse': np.array([0, 0.6, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100 }
]
在本例中,球体分别为红色、品红色和绿色。
根据 Blinn-Phong 模型,光线也有三种颜色属性:环境光、漫反射光和镜面反射光。让我们把它们也加上。
light = { 'position': np.array([5, 5, 5]), 'ambient': np.array([1, 1, 1]), 'diffuse': np.array([1, 1, 1]), 'specular': np.array([1, 1, 1]) }
鉴于这些特性,Blinn-Phong 模型对一个点的照度计算如下:
ka、kd、ks 表示物体的环境、漫反射和镜面特性;ia、id、is 是光线的环境、漫反射和镜面特性;L 是交点朝向光线的单位方向矢量;N 是交点处物体表面的单位法向量;V 是交叉点朝向摄像机的方向单位矢量;α 是物体的光泽度。
if is_shadowed:
break
# RGB
illumination = np.zeros((3))
# ambiant
illumination += nearest_object['ambient'] * light['ambient']
# diffuse
illumination += nearest_object['diffuse'] * light['diffuse'] * np.dot(intersection_to_light, normal_to_surface)
# specular
intersection_to_camera = normalize(camera - intersection)
H = normalize(intersection_to_light + intersection_to_camera)
illumination += nearest_object['specular'] * light['specular'] * np.dot(normal_to_surface, H) ** (nearest_object['shininess'] / 4)
image[i, j] = np.clip(illumination, 0, 1)
请注意,最后,我们将颜色限制在 0 和 1 之间,以确保它在正确的范围内。
运行代码
增加宽度和高度以获得更高的分辨率(以时间为代价)
不过,你可能会注意到有两点与我在开头展示的第一张图片不同。灰色地板不见了;这张图片中没有反光(镜面效果);让我们来解决这两点。
虚假的平面
理想情况下,我们可以创建另一种类型的对象,即平面,但由于我们比较懒惰,我们可以简单地使用另一个球体。如果你站在一个半径无限大(与你的体型相比)的球体上,那么你就会感觉自己站在一个平面上。将这个球体添加到对象列表中,然后再次渲染!
{ 'center': np.array([0, -9000, 0]), 'radius': 9000 - 0.7, 'ambient': np.array([0.1, 0.1, 0.1]), 'diffuse': np.array([0.6, 0.6, 0.6]), 'specular': np.array([1, 1, 1]), 'shininess': 100 }
反射
现在,我们渲染的光线是:从光源射出,击中物体表面,然后直接弹向摄像机。如果光线在击中摄像机之前击中了多个物体呢?这就是反射。光线会累积不同的颜色,当它击中摄像机时,你会看到反射的效果。
每个物体的反射系数范围为 0-1。0 "表示物体是无光泽的,"1 "表示物体像一面镜子。让我们为所有球体添加一个反射属性:
{ 'center': np.array([-0.2, 0, -1]), ..., 'reflection': 0.5 }
{ 'center': np.array([0.1, -0.3, 0]), ..., 'reflection': 0.5 }
{ 'center': np.array([-0.3, 0, 0]), ..., 'reflection': 0.5 }
{ 'center': np.array([0, -9000, 0]), ..., 'reflection': 0.5 }
算法实现
目前,我们先计算一条从摄像机出发并朝像素方向延伸的射线,然后将该射线追踪到场景中,检查最近的交点并计算交点的颜色。为了包含反射,我们需要在交点发生后追踪反射光线,并包含每个交点的颜色贡献。我们将重复这一过程若干次。
颜色计算
为了得到一个像素的颜色,我们需要将射线每个交叉点的贡献值相加。
其中,c 是像素的(最终)颜色;i 是根据 Blinn-Phong 模型计算的#index 交点的光照度;r 是 #index 交点对象的反射;然后自行决定何时停止计算该总和(即何时停止追踪反射光线)。
反射光线
在编码之前,我们需要找到反射光线的方向。我们可以用下面的方法计算反射光线:
其中,R 是归一化的反射光线;V 是被反射光线的方向单位矢量;N 是射线划过的表面的法线方向单位矢量;将此方法与 normalize() 函数一起添加到文件顶部:
def reflected(vector, axis):
return vector - 2 * np.dot(vector, axis) * axis
代码
迭代反射只需要添加一小段代码
# global variable along with width, height, etc.
max_depth = 3
# everything that follows is inside the double for loop
color = np.zeros((3))
reflection = 1
for k in range(max_depth):
nearest_object, min_distance = # ...
# ...
illumination += # ...
# reflection
color += reflection * illumination
reflection *= nearest_object['reflection']
# new ray origin and direction
origin = shifted_point
direction = reflected(direction, normal_to_surface)
image[i, j] = np.clip(color, 0, 1)
运行代码得到最终的渲染图片!
最终代码
只不过一百多行~
import numpy as np
import matplotlib.pyplot as plt
def normalize(vector):
return vector / np.linalg.norm(vector)
def reflected(vector, axis):
return vector - 2 * np.dot(vector, axis) * axis
def sphere_intersect(center, radius, ray_origin, ray_direction):
b = 2 * np.dot(ray_direction, ray_origin - center)
c = np.linalg.norm(ray_origin - center) ** 2 - radius ** 2
delta = b ** 2 - 4 * c
if delta > 0:
t1 = (-b + np.sqrt(delta)) / 2
t2 = (-b - np.sqrt(delta)) / 2
if t1 > 0 and t2 > 0:
return min(t1, t2)
return None
def nearest_intersected_object(objects, ray_origin, ray_direction):
distances = [sphere_intersect(obj['center'], obj['radius'], ray_origin, ray_direction) for obj in objects]
nearest_object = None
min_distance = np.inf
for index, distance in enumerate(distances):
if distance and distance < min_distance:
min_distance = distance
nearest_object = objects[index]
return nearest_object, min_distance
width = 300
height = 200
max_depth = 3
camera = np.array([0, 0, 1])
ratio = float(width) / height
screen = (-1, 1 / ratio, 1, -1 / ratio) # left, top, right, bottom
light = { 'position': np.array([5, 5, 5]), 'ambient': np.array([1, 1, 1]), 'diffuse': np.array([1, 1, 1]), 'specular': np.array([1, 1, 1]) }
objects = [
{ 'center': np.array([-0.2, 0, -1]), 'radius': 0.7, 'ambient': np.array([0.1, 0, 0]), 'diffuse': np.array([0.7, 0, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 },
{ 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1, 'ambient': np.array([0.1, 0, 0.1]), 'diffuse': np.array([0.7, 0, 0.7]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 },
{ 'center': np.array([-0.3, 0, 0]), 'radius': 0.15, 'ambient': np.array([0, 0.1, 0]), 'diffuse': np.array([0, 0.6, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 },
{ 'center': np.array([0, -9000, 0]), 'radius': 9000 - 0.7, 'ambient': np.array([0.1, 0.1, 0.1]), 'diffuse': np.array([0.6, 0.6, 0.6]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 }
]
image = np.zeros((height, width, 3))
for i, y in enumerate(np.linspace(screen[1], screen[3], height)):
for j, x in enumerate(np.linspace(screen[0], screen[2], width)):
# screen is on origin
pixel = np.array([x, y, 0])
origin = camera
direction = normalize(pixel - origin)
color = np.zeros((3))
reflection = 1
for k in range(max_depth):
# check for intersections
nearest_object, min_distance = nearest_intersected_object(objects, origin, direction)
if nearest_object is None:
break
intersection = origin + min_distance * direction
normal_to_surface = normalize(intersection - nearest_object['center'])
shifted_point = intersection + 1e-5 * normal_to_surface
intersection_to_light = normalize(light['position'] - shifted_point)
_, min_distance = nearest_intersected_object(objects, shifted_point, intersection_to_light)
intersection_to_light_distance = np.linalg.norm(light['position'] - intersection)
is_shadowed = min_distance < intersection_to_light_distance
if is_shadowed:
break
illumination = np.zeros((3))
# ambiant
illumination += nearest_object['ambient'] * light['ambient']
# diffuse
illumination += nearest_object['diffuse'] * light['diffuse'] * np.dot(intersection_to_light, normal_to_surface)
# specular
intersection_to_camera = normalize(camera - intersection)
H = normalize(intersection_to_light + intersection_to_camera)
illumination += nearest_object['specular'] * light['specular'] * np.dot(normal_to_surface, H) ** (nearest_object['shininess'] / 4)
# reflection
color += reflection * illumination
reflection *= nearest_object['reflection']
origin = shifted_point
direction = reflected(direction, normal_to_surface)
image[i, j] = np.clip(color, 0, 1)
print("%d/%d" % (i + 1, height))
plt.imsave('image.png', image)
>感谢各位阅读~ 如果有所收获,欢迎关注。 您的支持是我创作的最大动力