从0到实时光线追踪(二):基于光线的光栅化

在上一节我们完成了基本的渲染数据类型,在这一节我们将试着使用写一个不带光照和阴影的简易光栅化渲染器,这个渲染器将是实时光线追踪渲染器的一个重要组成部分。

主渲染循环的构建

Initialize

在开始Taichi图形的运行前,我们需要使用ti.init()对Taichi进行初始化。由于在小场景中使用Vulkan加速器架构要比CUDA开启更快,因此我们首选使用Vulkan架构处理图形界面。同时,由于在随后的管线开发中,我们还需要进行更多的自定义初始化内容,因此我们新建一个Initialize.py文件,用于处理程序的初始化。

1
2
# in Initialize.py
ti.init(arch=ti.vulkan)

Fileds

首先,为了更好地管理内容,我们需要创建一个Fileds.py文件,用于存储数据。在光栅化进程中,我们暂时只需要一个image_pixels的张量(关于张量field的定义,请移步https://taichi-lang.cn/),用于记录每个像素的颜色信息,以及一个scene_objects的SDFObject类型张量,记录场景的模型信息。

1
2
3
4
5
6
7
8
# in Fields.py
Initialize.init()

image_pixels = ti.Vector.field(3, float)
ti.root.dense(ti.ij, RenderConfig.RESOLUTION_RATIO).place(image_pixels)

scene_objects_num = 0
scene_objects = SDFObject.field()

PredefinedMaterial

由于材质代码所占用的位置较多,因此为了美观性考虑,我们新建一个PredefinedMaterial.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
# in PredefinedMaterial.py
white_plastic = Material(
albedo=vec3(0.8, 0.8, 0.8) * 0.9,
roughness=1,
metallic=0.8,
)
red_plastic = Material(
albedo=vec3(0.6, 0.0, 0.0) * 0.9,
roughness=0.9,
metallic=0.01,
)
green_plastic = Material(
albedo=vec3(0.0, 0.6, 0.0) * 0.9,
roughness=0.9,
metallic=0.01,
)
light = Material(
albedo=vec3(1),
emission_color=vec3(1, 0.4, 0.3),
emission_intensity=5,
roughness=1,
metallic=1,
)
glass = Material(
albedo=vec3(1, 1, 1) * 0.99,
roughness=0,
metallic=0,
transmission=1,
IOR=1.88
)
mirror = Material(
albedo=vec3(1, 1, 1) * 0.9,
roughness=0,
metallic=1,
IOR=1.03)
white_iron = Material(
albedo=vec3(1, 1, 1) * 0.4,
roughness=0.88,
metallic=0.4,
IOR=0.53
)

其中,透明材质glass和镜面反射材质mirror在本节所实现的光栅化渲染器中无法正常渲染。因为在朴素的光栅化渲染器中,每个像素会被视为一个独立的点,这意味着我们不能考虑光线在材料表面上发生的反射和折射现象。虽说有一部分例如compute或是前向渲染这样的方法可以用于渲染半透明物体与反射物体,但是由于我们的渲染器会混合使用光栅化和光线追踪两种渲染方法,因此在此进行额外的计算是不必要的。

Scene

随后,我们新建一个Scene.py,用于初始化定义并处理场景信息。在当前的光栅化过程中,Scene暂时不涉及到对场景进行八叉树划分等加速操作,而仅仅用于存储场景的初始定义信息。在本文中,我们提供了一个简单的Cornell Box场景。

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
objects = sorted([
SDFObject(type=CUBE,
trans=Transform(vec3(1, 0, 0), vec3(0), vec3(0.01, 1, 1)),
mtl=white_iron
),
SDFObject(type=CUBE,
trans=Transform(vec3(-1, 0, 0), vec3(0), vec3(0.01, 1, 1)),
mtl=red_plastic
),
SDFObject(type=CUBE,
trans=Transform(vec3(0, 1, 0), vec3(0), vec3(1, 0.01, 1)),
mtl=green_plastic
),
SDFObject(type=CUBE,
trans=Transform(vec3(0, -1, 0), vec3(0), vec3(1, 0.01, 1)),
mtl=white_iron
),
SDFObject(type=CUBE,
trans=Transform(vec3(0, 0, -1), vec3(0), vec3(1, 1, 0.01)),
mtl=white_iron
),
SDFObject(type=CUBE,
trans=Transform(vec3(-0.3, -0.3, 0), vec3(0,45,0), vec3(0.3, 0.5, 0.2)),
mtl=green_plastic
),

], key=lambda o: o.mtl.emission_intensity)

# Initialize scene_objects as a sparse data structure by Taichi
ti.root.dense(ti.i, len(objects)).place(scene_objects)
scene_objects_num = len(objects)

在使用光线追踪对场景进行渲染时,将得到以下结果:Cornell Box by Shikaku Renderer

主程序

在main.py中,我们定义一个render()方法来控制每一帧的渲染。在测试环节中,我们给每个像素一个随机的色值,检测代码是否可以正确运行。

1
2
3
def render():
for i, j in image_pixels:
image_pixels[i, j] = vec3(ti.random())

随后我们编写程序的主循环过程。使用Taichi库的GGUI系统,我们可以很方便地创建一个图形化窗口。首先定义一个窗口和与之对应的窗口画布:

1
2
3
# the title of the window is 'Qin renderer'
window = ti.ui.Window("Qin renderer", RenderConfig.RESOLUTION_RATIO)
canvas = window.get_canvas()

随后定义一个Taichi相机,用以进行场景漫游。在定义相机时,需要同时定义相机的初始位置、朝向和上方。

1
2
3
4
taichi_camera = ti.ui.Camera()
taichi_camera.position(0, 0, 4.5)
taichi_camera.lookat(0, 0, 1)
taichi_camera.up(0, 1, 0)

随后我们使用while来实现一个主渲染循环。在循环过程中,我们不断调用render()函数来更新渲染结果,并将渲染结果显示在窗口上。同时,为了方便随后进行帧率同步,我们需要记录单次循环的运行时间。

1
2
3
4
5
6
7
8
9
while window.running:
start_time = time.time()

render()
canvas.set_image(image_pixels)

dt = time.time() - start_time

window.show()

此时运行main.py,将得到随机噪声形成的渲染结果:

Snipaste_y4512-23-y4N_12-1917-1719-1912.png

初级光线式前向渲染光栅化器

光栅化渲染包含延迟渲染和前向渲染两种。

前向渲染(Forward Rendering)是一种直接光照的渲染技术。在前向渲染中,每个像素都需要计算所有灯光的贡献,所以效率会受到灯光数量的影响。由于需要进行深度测试等操作,这也会使得前向渲染在处理大量物体时效率较低。但是,它将光照计算直接绑定到渲染管线中,因此在特效和后期处理方面具有灵活性和可定制性。

延迟渲染(Deferred Rendering)是将光照计算从光栅化阶段移动到了单独的缓冲区中。在延迟渲染中,首先将所有物体的几何数据、法线、材质等信息存储在一个叫做几何缓冲区的缓冲区中,然后再存储所有光源的信息在光照缓冲区中。最后,在一次遍历中,通过像素着色器使用存储在几何缓冲区中的信息来计算需要的颜色,同时使用光照缓冲区中的信息计算出像素的光照值。因为不需要在光栅化阶段进行光照计算,所以无论物体和灯光的数量增加多少,延迟渲染都具有极高的效率。但是,该方法可能会占用更多的显存,并且对于复杂的特效和后期处理有一定的限制。

在光栅化与光线追踪的混合渲染器中,通常会使用前向渲染来进行光栅化渲染。在混合渲染器中,由于需要快速渲染大量物体,前向渲染非常适合处理这种情况。前向渲染的特点是计算量较小,适合渲染物体数量较多的场景。同时,虽然前向渲染不能很好地处理细节和复杂的光照效果,但是这一部分在混合渲染器中完全可以由光线追踪部分负责,以此掩盖光栅化的缺点,因此前向光栅化渲染似乎正是一个不错的选择。

在最初级的入门前向渲染光栅化器中,我们不会考虑物体的光照与阴影对画面产生的影响,而是仅将画面图元映射到屏幕上。这一过程十分简单,我们只需要按以下步骤进行:

  • 遍历所有物体
  • 判断物体的包围盒是否落在屏幕内,如果是则进行下一步计算,否则跳过该物体。
  • 对于任意一个物体,如果是SDF物体,则遍历其包围盒中每一个点,判断其是否可以映射到屏幕上,如果是则将其映射到屏幕上。

然而,传统的光栅化过程是通过遍历物体、并将物体映射至屏幕空间来完成的。考虑到有向距离场物体的存在,以及光场物理相机等物理过程影响的因素我们决定使用基于光线的方法来实现半光栅化,或者说伪光栅化。我们新建一个Rasterization.py文件,用于编写光栅化过程。

基于光线的光栅化

在传统的光栅化中,我们总是通过遍历物体、将物体投影到屏幕空间来完成的。对于SDF有向距离场物体,由于不具有顶点,我们常通过将SDF物体的包围盒按坐标轴细分份,并遍历每一个像素来得到——这种方法的复杂度是,其中是物体的数量,是物体包围盒包含的像素量;对于顶点构成的基本几何模型,这一方法的复杂度大约是,其中是物体的顶点数,是物体投影后占用的像素点数。可以发现,在渲染SDF物体时,随着物体形状的精细化,需要检测的像素点个数呈三次立方的形式增长。同时,由于在我们的设计中包含了物理相机,在采样得到像素点后,还需要依据物理量对采样数据进行各种变换,其造成的误差将难以被接受。基于上述分析,我们可以很容易地得出结论,一般的光栅化方法并不能满足本文的需求。

因此,我们提出了一种基于光线的光栅化方法。这一方法是针对SDF物体渲染的Ray-Marching方法的优化方案,首先遍历屏幕上像素点,向每个像素点发射一条光线,找到第一个与之相交的物体上的相交点,这一点就是该像素点对应的采样点。我们假设屏幕空间内发射一条光线对物体求交的复杂度为,这一方法在渲染SDF物体时,假设场景中有个物体,渲染窗口大小为,其复杂度即为;对于普通物体,这一方法的则是物体的顶点数、屏幕大小和物体数的乘积,即

观察可知,当SDF物体的细分次数大于屏幕像素数量时,或当物体投影的像素数量大于屏幕像素数量时,这一方法效率更高。换句话说,场景越复杂、屏幕内物体数量越多,这一方法效率越优秀。

本文的光栅化方法正是基于这一一种思路实现的。可以预见的是,在小场景的渲染上,这一方法的效率相较传统方法有所不足,但是在处理较大场景时将会有更大的速度优势。

基于Taichi的GPU与CPU分配

在Taichi中,程序通过一种叫做声明式编程的方式描述计算任务,而不是传统的命令式编程方式。Taichi运行时会根据这些声明式的程序代码自动分析和优化任务,并将其在GPU和CPU之间进行智能分配,以最大限度地提高计算效率。

简单来说,对于任意一个函数,如果对其使用kernel修饰:

1
2
3
@ti.kernel
def test():
print(a)

这个函数就会在GPU上分布式运行。同时,在kernel修饰的函数中,会自动展开最外层的for循环,将运行任务分配到GPU的多内核上。然而,内层的for循环无法被自动展开,而是堆砌到GPU的单个核心上进行计算。

1
2
3
4
5
@ti.kernel
def test():
for i in range(8): # 这一层循环将被分配到多个计算单元上
for j in range(7): # 这一层循环将停留在单个计算单元上
print(b)

同时,被kernel修饰的函数只能调用被func修饰的函数,被func修饰的函数可以调用没有被func修饰的函数,也可以调用其它被func修饰的函数。然而,被kernel修饰的函数只能在没有修饰的情况下进行调用,被func修饰的函数也只能被kernel调用。

考虑遍历所有个物体、并遍历其包围盒内的个像素,总的复杂度为。由于类的独立性,只能选择其中一个进行展开,此时显然需要遍历的像素点总数远大于物体数,故对于遍历像素点的函数,我们将其设置为kernel,使用GPU计算;对于遍历个物体的函数,我们不进行修饰,使用单一计算单元计算。

1
2
3
4
5
6
7
8
9
10
@ti.kernel
def rasterization():
for i,j in pixel_images:
shade()


@ti.func
def shade():
# do some things
return 0

光线物体求交

着色中最重要的部分是,射出一条光线后判断与其相交的物体。在当前测试的SDF物体中,我们认为,当光线目前传播到的点和SDF物体方程之间的距离小于一个常数,那么这个点就是光线与物体的交点。联立二者方程组,有:

那么判断物体是否相交就十分容易了:

1
2
3
hit = False
if sdf_dis(p,obj)<=1e-4:
hit = True

那么对于一条光线的任意时刻,我们可以依次遍历所有物体,求出物体是否与光线相交。如果光线在一轮遍历中没有与任何物体相交,则使得光线前进一段距离,这段距离正是光线与所有物体之间的最小距离。这一方法的基本逻辑是,以光线所前进的距离为半径的圆内不包含任何其他物体。因此,我们的代码分为两部分,一部分描述了光线步进的过程,另一部分描述了距离光线当前原点最近的物体。

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
@ti.func
def ray_cast(ray: Ray) -> tuple[bool, int, float]:
hit, index, dis = False, -1, 1e9
t, w, s = 0.0, 1.0, 0.0
for i in range(1024):
lst_dis = dis
index, dis = nearest_SDFobject(ray)

if w > 1.0 and lst_dis + dis < s:
s -= w * s
w = 1.0
t += s
ray.origin += ray.direction * s
continue
s = w * dis
t += s
ray.origin += ray.direction * s
hit = dis < t * 1e-4

if hit or t >= 1e5:
break
return hit, index, dis


@ti.func
def nearest_SDFobject(ray: Ray) -> tuple[int, float]:
index = -1
res = 1e9
for i in range(scene_objects_num):
if ray_inter_bounding_box(ray, scene_objects[i].bound):
dis = abs(sdf_dis(ray.origin, scene_objects[i]))
if dis < res:
index = i
res = dis
return index, res

在这里,我们使用了线性光线步进方法,在光线越靠近屏幕时越精确,越远离屏幕时越模糊。考虑优化求交过程,如果光线与物体的包围盒不相交,则直接跳过该物体。

此时,在rasterization方法中应用我们的函数ray_cast,就可以得到最基础的光栅化效果了。

1
2
3
4
5
6
7
8
9
10
11
@ti.kernel
def rasterization(p: vec3, l: vec3, u: vec3):
for i, j in image_pixels:
image_pixels[i, j] = vec3(0)
uv = vec2(i, j) * SCREEN_PIXEL_SIZE

ray = get_direct_ray(p, l, u, uv, vec3(0))
hit, index, dis = ray_cast(ray)

if hit:
image_pixels[i, j] = scene_objects[index].mtl.albedo

iIq6mo.png

应用Blinn-Phong光照模型

根据Blinn-Phong模型,要求物体的光照情况,就要知道物体的法线。对于三维模型而言,物体的法线可以直接从模型上获取;然而,对于有向距离场物体,法线则需要通过计算得到。

对于点的法线,我们通过向该点四周移动一个极小的偏移量,来计算出物体在点处的法线。对于每个方向,它对应的偏移量,我们分别计算移动偏移量到物体表面的距离,并将所有偏移量与距离相乘并加起来,此时有:

这里的即为物体在点处的法线向量,归一化后即为物体法线。证明部分如下:

。对于一个SDF函数,在点处的梯度可以表示为:

接下来,考虑如何通过计算它的梯度。由于SDF函数是一个标量场,因此可以使用数值微积分中的有限差分法来近似计算梯度。这里采用的是中心差分公式。对于第维坐标(),梯度的中心差分可以表示为:

其中,表示第维的单位向量,即,其中第个分量为1,其余分量均为0。这样计算得到的梯度就是一个单位向量。

1
2
3
4
5
6
7
@ti.func
def get_SDF_normal(obj, p: vec3) -> tm.vec3:
e = tm.vec2(1, -1) * 0.02
return tm.normalize(e.xyy * sdf_dis(p + e.xyy, obj) +
e.yyx * sdf_dis(p + e.yyx, obj) +
e.yxy * sdf_dis(p + e.yxy, obj) +
e.xxx * sdf_dis(p + e.xxx, obj))

根据代码可以看出,实现中我们采用了四个方向的偏移向量,对应于三个方向和对角线方向。然后在每个位置分别计算四个方向上的SDF值,并分别乘以对应方向的向量,最后相加并进行归一化即可得到局部法向量。

随后,我们需要考虑模型的光照效果。我们先实现一个简单的点光源。对于非自发光物体,我们直接通过Blinn-Phong模型,得到物体的亮度即可。Blinn-Phong模型实现起来十分简单,我们通过while循环是为了遍历整个场景中的光源,并计算出该位置所有光源对该位置的贡献,在每次循环中,首先计算从该位置到光源的向量to_light,并将其归一化得到方向向量i。然后根据离光源的距离和光源强度计算出实际的光强度i_intensity。接下来分别计算漫反射和镜面反射的贡献,最终计算出该位置所有光源的漫反射和镜面反射强度之和,并将其存储在变量ld和ls中。最后,将遍历到的光源索引进行递减,继续寻找下一个光源,直至场景中没有光源或遍历完所有光源,才完成计算并返回该位置的光照强度。

注意,我们新定义了一个常量EMISSION,用于度量环境光照。

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
@ti.func
def illumination(ray: Ray, pos: vec3, index: int) -> float:
flag = scene_objects_num - 1
le, ld, ls = EMISSION, 0.0, 0.0
normal = get_SDF_normal(scene_objects[index], pos)
roughness = scene_objects[index].mtl.roughness
metallic = scene_objects[index].mtl.metallic
while scene_objects[flag].mtl.emission_intensity > 0 and flag >= 0:
# preparation
to_light = scene_objects[flag].trans.position - pos
i = normalize(to_light)
i_dist = length(to_light)
i_intensity = scene_objects[flag].mtl.emission_intensity / (4.0 * pi * i_dist ** 2)

# diffuse reflection
ndl = max(0.0, dot(normal, i)) # N dot L
ld += i_intensity * ndl

# specular reflection
half = normalize(i - normalize(ray.direction))
ns = pow(max(0.0, dot(normal, half)), scene_objects[flag].mtl.metallic * 10) # N dot H
ls += i_intensity * ns

flag -= 1

return le + ld + ls

随后,我们将场景中的长方体修改为球体、并将其材质设置为light,即可得到如下的渲染画面:

iNUaKP.png

到目前为止,我们已经得到了一个不错的、带光照的光栅化渲染器,但是它依然十分简陋且低效。在随后的开发过程中,我们将实现自发光物体渲染、进一步优化光线求交、添加环境光遮罩,并对渲染器进行更多优化。

到目前为止,我们已经完成了以下内容:

Chapter2 Mindmap