PyTinyRenderer软渲染器-04
在之前的旅程中,我们已经成功地通过Z-Buffer算法已经很完美的处理了嘴唇凹进去的阴影面缺显示在上层的问题,并且把纹理贴在了模型上,达到了我们期望的基本效果。但是我们一直都是从正面视角去观察这个模型,既然是一个渲染引擎,当然也要能切换各个角度去渲染模型,所以本篇笔记的核心内容是理解和掌握透视投影的原理,以及如何利用坐标系变换的思想去推导视图变换矩阵,从而实现移动相机从其他角度渲染模型。对了还有实战使用三种着色方法(Flat Shading、Gouraud Shading、Phong Shading)进行片元着色。
透视投影与正交投影
在之前有一篇笔记着重介绍了这部分 ——《M-V-P变换与透视投影矩阵的推导》,这篇是基于计算机图形学 (闫令琪老师)在GAMES101中的课程总结出的。M-V-P 变换是模型变换、视图变换和投影变换的结合,用于将三维空间中的内容显示到二维屏幕上。文中首先介绍了仿射变换,并解释了三维空间中缩放、平移和旋转等基本操作。接着详述了视图变换过程,即通过平移和旋转矩阵调整摄像机坐标系与世界坐标系重合。然后讨论了正交投影与透视投影的区别及其实现方式,特别强调透视投影近大远小的效果以及如何通过齐次坐标系统进行 “挤压” 操作来完成从透视到正交的映射。最后提出了经过模型、视图、投影和视口四个步骤后可以成功地将游戏世界内物体呈现在屏幕上,并总结这一流程并不复杂只要理清各步骤即可。
需要理解比较重要的几个概念:
线性变换、仿射变换、齐次坐标、透视投影、M-V-P变换,这个几个概念搞清楚了再看接下来的内容。我们先定义一个Matrix矩阵类,用于矩阵的常用操作,当然为了快捷直接使用了numpy:
1import numpy as np
2
3
4class Matrix:
5 def __init__(self, r=4, c=4):
6 self.m = np.zeros((r, c), dtype=float)
7 self.rows = r
8 self.cols = c
9
10 @staticmethod
11 def identity(dimensions):
12 mat = Matrix(dimensions, dimensions)
13 mat.m = np.eye(dimensions, dtype=float)
14 return mat
15
16 def __getitem__(self, i):
17 assert 0 <= i < self.rows
18 return self.m[i]
19
20 def __mul__(self, a):
21 assert self.cols == a.rows
22 return Matrix.from_np(self.m @ a.m)
23
24 def transpose(self):
25 return Matrix.from_np(np.transpose(self.m))
26
27 def inverse(self):
28 assert self.rows == self.cols
29 return Matrix.from_np(np.linalg.inv(self.m))
30
31 @staticmethod
32 def from_np(np_matrix):
33 m = Matrix(np_matrix.shape[0], np_matrix.shape[1])
34 m.m = np_matrix
35 return m
36
37 def __str__(self):
38 return str(self.m)
下面开始进行透视投影变换的流程,因为暂时不做model、view变换,所以我们只是简单的返回一个单位矩阵即可。
1# 摄像机摆放的位置
2cameraPos = Vec3([0, 0, 3])
3
4
5def local_2_homo(v: Vec3):
6 """
7 局部坐标变换成齐次坐标
8 """
9 m = Matrix(4, 1)
10 m[0][0] = v.x
11 m[1][0] = v.y
12 m[2][0] = v.z
13 m[3][0] = 1.0
14 return m
15
16
17# 模型变换矩阵
18def model_matrix():
19 return Matrix.identity(4)
20
21
22# 视图变换矩阵
23def view_matrix():
24 return Matrix.identity(4)
25
26
27# 透视投影变换矩阵
28def projection_matrix():
29 projection = Matrix.identity(4)
30 projection[3][2] = -1.0 / cameraPos.z
31 return projection
32
33
34# 此时我们的所有的顶点已经经过了透视投影变换,接下来需要进行透视除法
35def projection_division(m: Matrix):
36 m[0][0] = m[0][0] / m[3][0]
37 m[1][0] = m[1][0] / m[3][0]
38 m[2][0] = m[2][0] / m[3][0]
39 m[3][0] = 1.0
40 return m
41
42
43def viewport_matrix(x, y, w, h, depth):
44 """
45 视口变换将NDC坐标转换为屏幕坐标
46 """
47 m = Matrix.identity(4)
48 m[0][3] = x + w / 2.
49 m[1][3] = y + h / 2.
50 m[2][3] = depth / 2.
51
52 m[0][0] = w / 2.
53 m[1][1] = h / 2.
54 m[2][2] = depth / 2.
55 return m
56
57
58def homo_2_vertices(m: Matrix):
59 """
60 去掉第四个分量,将其恢复到三维坐标
61 """
62 return Vec3([int(m[0][0]), int(m[1][0]), int(m[2][0])])
63
64
65if __name__ == '__main__':
66 width = 600
67 height = 600
68 depth = 255
69
70 tga: Image = Image.open('african_head_diffuse.tga')
71
72 image = MyImage((width, height))
73
74 # -sys.maxsize - 1 最小值
75 z_buffer = [-sys.maxsize - 1] * width * height
76
77 obj = OBJFile('african_head.obj')
78 obj.parse()
79
80 model_ = model_matrix()
81 view_ = view_matrix()
82 projection_ = projection_matrix()
83 viewport_ = viewport_matrix(width / 8, height / 8, width * 3 / 4, height * 3 / 4, depth)
84
85 light_dir = Vec3([0, 0, -1])
86 gamma = 2.2
87 for face in obj.faces:
88 screen_coords = [None, None, None] # 第i个面片三个顶点的屏幕坐标
89 world_coords = [None, None, None] # 第i个面片三个顶点的世界坐标
90 uv_coords = [None, None, None]
91 for j in range(3):
92 v: Vec3 = obj.vert(face[j][0])
93 world_coords[j] = v
94 uv_coords[j] = obj.texcoord(face[j][1]) # 获取纹理坐标
95
96 screen_coords[j] = homo_2_vertices(viewport_ * projection_division(
97 projection_ * view_ * model_ * local_2_homo(v)))
98
99 # 计算三角形的法向量和光照强度
100 n: Vec3 = (world_coords[2] - world_coords[0]).cross(world_coords[1] - world_coords[0])
101 n.normalize()
102 intensity = n * light_dir
103 # 负的就剔除掉
104 if intensity > 0:
105 intensity = intensity ** (1 / gamma)
106 triangle(screen_coords[0], screen_coords[1], screen_coords[2],
107 uv_coords[0], uv_coords[1], uv_coords[2],
108 intensity, image, tga)
109
110 image.save('out.bmp')
对比正交投影(左侧)和透视投影(右侧)不难发现,透视投影的渲染结果更具有真实感:
坐标系变换推导
其实核心就是就是我们要理解坐标系变换,即如何在两个不同的坐标系之间转换点的坐标。这在计算机图形学中是一个重要的概念,因为我们经常需要在世界坐标系、视图坐标系、物体坐标系等之间进行转换。
首先在欧几里得空间中,坐标可以由一个点(原点)和一组基向量给出。如果点P在坐标系(O, i, j, k)中的坐标为(x, y, z),那么向量OP可以表示为:
$$\vec{OP} = x\vec{i} + y\vec{j} + z\vec{k}$$
然后,假设我们有另一个坐标系(O', i', j', k')。我们如何将一个坐标系中的坐标转换到另一个坐标系呢?首先,由于(i, j, k)和(i', j', k')都是3D空间的基,存在一个逆矩阵M,使得:
$$\begin{bmatrix} \vec{i'} \\ \vec{j'} \\ \vec{k'} \end{bmatrix} = M \begin{bmatrix} \vec{i} \\ \vec{j} \\ \vec{k} \end{bmatrix}$$
然后,我们重新表示向量OP:
$$\overrightarrow{OP}=\overrightarrow{OO'}+\overrightarrow{O’P}=\begin{bmatrix}\vec{i}&\vec{j}&\vec{k}\end{bmatrix}\begin{bmatrix}O'_x\\O'_y\\O'_z\end{bmatrix}+\begin{bmatrix}\vec{i'}&\vec{j'}&\vec{k'}\end{bmatrix}\begin{bmatrix}x'\\y'\\z'\end{bmatrix}$$
现在,我们用基变换矩阵替换右侧的(i', j', k'):
$$\overrightarrow{OP}=\begin{bmatrix}\vec{i}&\vec{j}&\vec{k}\end{bmatrix}\left(\begin{bmatrix}O_x'\\O_y'\\O_z'\end{bmatrix}+M\begin{bmatrix}x'\\y'\\z'\end{bmatrix}\right)$$
这就给出了从一个坐标系到另一个坐标系的坐标变换公式,我们经常需要在不同的坐标系之间转换坐标。
$$\begin{bmatrix}x\\y\\z\end{bmatrix}=\begin{bmatrix}O'_x\\O'_y\\O'_z\end{bmatrix}+M\begin{bmatrix}x'\\y'\\z'\end{bmatrix}\quad\Rightarrow\quad\begin{bmatrix}x'\\y'\\z'\end{bmatrix}=M^{-1}\left(\begin{bmatrix}x\\y\\z\end{bmatrix}-\begin{bmatrix}O'_x\\O'_y\\O'_z\end{bmatrix}\right)$$
所以结论就是:对坐标轴进行M变换,实际上相当于对坐标进行M的逆变换。
在《M-V-P变换与透视投影矩阵的推导》这篇笔记中,其实阅读视图变换 (view transformation)这部一部分的内容,我们很容易推断出视图变换矩阵。总结来看就是两个步骤:将相机位置移动至原点以及通过旋转矩阵将二者坐标系重合。
我们可以这样理解,如果要在世界坐标系下的 (x、y、z) 位置设置相机,那么我们把相机再移回世界坐标系原点的位移就是(-x、-y、-z)。所以我们当以相机为坐标原点的时候,所有在原来坐标系下的物体都要加上这个负的平移分量。那么这个平移矩阵如下:
$$\begin{bmatrix}1&0&0&-x\\0&1&0&-y\\0&0&1&-z\\0&0&0&1\end{bmatrix}$$
我们将原来的坐标系转变成相机坐标系,不仅需要平移到相机位置,还要旋转到相机的朝向。我们要将蓝色的坐标系通过旋转变换成红色的相机坐标系。由于坐标系的三个基向量都是单位化的,所以最简单的办法就是点乘,做法就是点乘相机坐标系的三个基向量:
$$\begin{bmatrix}U_x&U_y&U_z&0\\V_x&V_y&V_z&0\\N_x&N_y&N_z&0\\0&0&0&1\end{bmatrix}\begin{bmatrix}x_{world}\\y_{world}\\z_{world}\\1\end{bmatrix}=\begin{bmatrix}x_{camera}\\y_{camera}\\z_{camera}\\1\end{bmatrix}$$
其中 V 指向相机坐标系的 y 轴,U 指向相机坐标系的 x 轴,N 指向相机坐标系的 z 轴。这两个矩阵就组成了视图变换矩阵:
$$\begin{bmatrix}r_{x}&r_{y}&r_{z}&-e\cdot r\\u_{x}&u_{y}&u_{z}&-e\cdot u\\v_{x}&v_{y}&v_{z}&-e\cdot v\\0&0&0&1\end{bmatrix}$$
移动相机代码实现
定义Camera类表示相机:
1from vector import Vec3
2
3
4class Camera:
5 def __init__(self, eye_p: Vec3 = Vec3([0, 0, 0]),
6 world_up: Vec3 = Vec3([0, 1, 0]),
7 front: Vec3 = Vec3([0, 0, -1])):
8 self.position = eye_p
9 self.word_up = world_up
10 self.front = front.normalize()
11 self.right = self.front.cross(self.word_up).normalize()
12 self.up = self.right.cross(self.front).normalize()
这里的成员变量分别对应:
-
position:即相机在世界坐标系中的位置;
-
world_up:我们的辅助向量,一般设置为(0, 1, 0),即Y轴的正向朝向;
-
front:对应我们之前推导的v轴的反方向,u轴的方向是从c——>e,即从观察点指向相机中心,但是我们一般常说相机的朝向总是认为是从相机位置指向观察点的,即e——–>c
-
right:对应我们之前推导的r轴的方向;
-
up,对应我们之前推导的u轴的方向;
这里还需要在Vec3中加入get获得x、y、z的方法:
1 def get(self, i):
2 if i == 0:
3 return self.x
4 elif i == 1:
5 return self.y
6 elif i == 2:
7 return self.z
在上面的透视投影中,视图变换矩阵 view_matrix给的一个单位矩阵,现在只需要使用真实的视图变换矩阵即可,其它代码不用变化:
1def view_matrix(camera: Camera):
2 r_inverse = np.identity(4)
3 t_inverse = np.identity(4)
4 for i in range(3):
5 r_inverse[0][i] = camera.right.get(i)
6 r_inverse[1][i] = camera.up.get(i)
7 r_inverse[2][i] = -camera.front.get(i)
8
9 t_inverse[i][3] = -camera.position.get(i)
10 view = np.dot(r_inverse, t_inverse)
11 return Matrix.from_np(view)
下面给出完整代码:
1import sys
2
3import numpy as np
4from PIL import Image
5
6from camera import Camera
7from image import MyImage
8from matrix import Matrix
9from obj import OBJFile
10from vector import Vec3, Vec2
11
12white = (255, 255, 255)
13red = (255, 0, 0)
14green = (0, 255, 0)
15blue = (0, 0, 255)
16
17
18def triangle_area_2d(a: Vec2, b: Vec2, c: Vec2) -> float:
19 """
20 计算三角形面积
21 """
22 return .5 * ((b.y - a.y) * (b.x + a.x) + (c.y - b.y) * (c.x + b.x) + (a.y - c.y) * (a.x + c.x))
23
24
25def barycentric(A, B, C, P):
26 """
27 计算重心坐标 u, v, w
28 """
29 total_area = triangle_area_2d(A, B, C)
30 if total_area == 0:
31 return None # 或者抛出一个异常,或者返回一个特殊的值
32 u = triangle_area_2d(P, B, C) / total_area
33 v = triangle_area_2d(P, C, A) / total_area
34 w = triangle_area_2d(P, A, B) / total_area
35 return Vec3([u, v, w])
36
37
38def triangle(p0: Vec3, p1: Vec3, p2: Vec3,
39 uv0: Vec2, uv1: Vec2, uv2: Vec2,
40 intensity, img: MyImage, tga: Image):
41 min_x = max(0, min(p0.x, p1.x, p2.x))
42 max_x = min(img.width - 1, max(p0.x, p1.x, p2.x))
43 min_y = max(0, min(p0.y, p1.y, p2.y))
44 max_y = min(img.height - 1, max(p0.y, p1.y, p2.y))
45 P = Vec2((0, 0))
46
47 # 遍历包围盒内的每个像素
48 for P.y in range(min_y, max_y + 1):
49 for P.x in range(min_x, max_x + 1):
50 # 计算当前像素的重心坐标
51 bc_screen = barycentric(p0, p1, p2, P)
52 if bc_screen is None:
53 continue
54 # 如果像素的重心坐标的任何一个分量小于0,那么这个像素就在三角形的外部,我们就跳过它
55 if bc_screen.x < 0 or bc_screen.y < 0 or bc_screen.z < 0:
56 continue
57
58 # 使用重心坐标来插值纹理坐标
59 uv = uv0 * bc_screen.x + uv1 * bc_screen.y + uv2 * bc_screen.z
60 # 使用插值后的纹理坐标来从TGA文件中获取颜色
61 # 此TGA文件是从左上角开始的,所以需要将纵坐标反转
62 color = tga.getpixel((int(uv.x * tga.width), tga.height - 1 - int(uv.y * tga.height)))
63 color = (int(color[0] * intensity), int(color[1] * intensity), int(color[2] * intensity))
64
65 # 计算当前像素的深度
66 z = p0.z * bc_screen.x + p1.z * bc_screen.y + p2.z * bc_screen.z
67
68 # 检查Z缓冲区,如果当前像素的深度比Z缓冲区中的值更近,那么就更新Z缓冲区的值,并绘制像素
69 idx = P.x + P.y * img.width
70 if z_buffer[idx] < z:
71 z_buffer[idx] = z
72 image.putpixel((P.x, P.y), color)
73
74
75# 摄像机摆放的位置
76eye_position = Vec3([1, 1, 3])
77center = Vec3([0, 0, 0])
78
79camera = Camera(eye_position, Vec3([0, 1, 0]), center - eye_position)
80
81def local_2_homo(v: Vec3):
82 """
83 局部坐标变换成齐次坐标
84 """
85 m = Matrix(4, 1)
86 m[0][0] = v.x
87 m[1][0] = v.y
88 m[2][0] = v.z
89 m[3][0] = 1.0
90 return m
91
92
93# 模型变换矩阵
94def model_matrix():
95 return Matrix.identity(4)
96
97# 视图变换矩阵
98def view_matrix(camera: Camera):
99 r_inverse = np.identity(4)
100 t_inverse = np.identity(4)
101 for i in range(3):
102 r_inverse[0][i] = camera.right.get(i)
103 r_inverse[1][i] = camera.up.get(i)
104 r_inverse[2][i] = -camera.front.get(i)
105
106 t_inverse[i][3] = -camera.position.get(i)
107 view = np.dot(r_inverse, t_inverse)
108 return Matrix.from_np(view)
109
110
111# 透视投影变换矩阵
112def projection_matrix():
113 projection = Matrix.identity(4)
114 projection[3][2] = -1.0 / eye_position.z
115 return projection
116
117
118# 此时我们的所有的顶点已经经过了透视投影变换,接下来需要进行透视除法
119def projection_division(m: Matrix):
120 m[0][0] = m[0][0] / m[3][0]
121 m[1][0] = m[1][0] / m[3][0]
122 m[2][0] = m[2][0] / m[3][0]
123 m[3][0] = 1.0
124 return m
125
126
127def viewport_matrix(x, y, w, h, depth):
128 """
129 视口变换将NDC坐标转换为屏幕坐标
130 """
131 m = Matrix.identity(4)
132 m[0][3] = x + w / 2.
133 m[1][3] = y + h / 2.
134 m[2][3] = depth / 2.
135
136 m[0][0] = w / 2.
137 m[1][1] = h / 2.
138 m[2][2] = depth / 2.
139 return m
140
141
142def homo_2_vertices(m: Matrix):
143 """
144 去掉第四个分量,将其恢复到三维坐标
145 """
146 return Vec3([int(m[0][0]), int(m[1][0]), int(m[2][0])])
147
148
149if __name__ == '__main__':
150 width = 1600
151 height = 1600
152 depth = 255
153
154 tga: Image = Image.open('african_head_diffuse.tga')
155
156 image = MyImage((width, height))
157
158 # -sys.maxsize - 1 最小值
159 z_buffer = [-sys.maxsize - 1] * width * height
160
161 obj = OBJFile('african_head.obj')
162 obj.parse()
163
164 model_ = model_matrix()
165 view_ = view_matrix(camera)
166 projection_ = projection_matrix()
167 viewport_ = viewport_matrix(width / 8, height / 8, width * 3 / 4, height * 3 / 4, depth)
168
169 light_dir = Vec3([0, 0, -1])
170 gamma = 2.2
171 for face in obj.faces:
172 screen_coords = [None, None, None] # 第i个面片三个顶点的屏幕坐标
173 world_coords = [None, None, None] # 第i个面片三个顶点的世界坐标
174 uv_coords = [None, None, None]
175 for j in range(3):
176 v: Vec3 = obj.vert(face[j][0])
177 world_coords[j] = v
178 uv_coords[j] = obj.texcoord(face[j][1]) # 获取纹理坐标
179
180 screen_coords[j] = homo_2_vertices(viewport_ * projection_division(
181 projection_ * view_ * model_ * local_2_homo(v)))
182
183 # 计算三角形的法向量和光照强度
184 n: Vec3 = (world_coords[2] - world_coords[0]).cross(world_coords[1] - world_coords[0])
185 n.normalize()
186 intensity = n * light_dir
187 # 负的就剔除掉
188 if intensity > 0:
189 intensity = intensity ** (1 / gamma)
190 triangle(screen_coords[0], screen_coords[1], screen_coords[2],
191 uv_coords[0], uv_coords[1], uv_coords[2],
192 intensity, image, tga)
193
194 image.save('out.bmp')
下面看看效果,我们已经成功通过把相机 “移动” 到侧面对模型进行了渲染:
三种着色方式
计算机图形学中,着色(Shading)是一个重要的概念,它决定了物体表面的颜色和亮度。常见以下三种:
-
Flat Shading:平面着色是最简单的着色技术。在这种技术中,每个多边形(通常是三角形)都被赋予一个单一的颜色。这个颜色通常是由多边形的法线向量和光源的方向决定的。因为每个多边形只有一个颜色,所以结果通常会显得非常“平坦”,没有渐变效果。这种技术的优点是计算简单,但缺点是视觉效果较差。
-
Gouraud Shading:高洛德着色是一种改进的着色技术。在这种技术中,每个顶点都被赋予一个颜色,然后在多边形内部进行插值,以产生渐变效果。这种技术的优点是可以产生较为平滑的视觉效果,但缺点是可能会出现“亮点”(即光源直接照射的地方)的渲染不准确。
-
Phong Shading:冯氏着色是一种更进一步的改进技术。在这种技术中,每个像素都被赋予一个颜色。颜色的计算是在每个像素位置进行的,而不是在顶点位置。然后,颜色会根据法线向量和光源的方向进行插值。这种技术的优点是可以产生非常平滑并且准确的视觉效果,特别是在渲染亮点方面。但是,这种技术的缺点是计算复杂度较高。
下面根据这三种着色方法,逐一进行对比:
Flat Shading
1def triangle(p0: Vec3, p1: Vec3, p2: Vec3,
2 uv0: Vec2, uv1: Vec2, uv2: Vec2,
3 intensity, img: MyImage, tga: Image):
4 min_x = max(0, min(p0.x, p1.x, p2.x))
5 max_x = min(img.width - 1, max(p0.x, p1.x, p2.x))
6 min_y = max(0, min(p0.y, p1.y, p2.y))
7 max_y = min(img.height - 1, max(p0.y, p1.y, p2.y))
8 P = Vec2((0, 0))
9
10 # 计算三角形的颜色 Flat Shading
11 uv = Vec3([(uv0 + uv1 + uv2).x/3, (uv0 + uv1 + uv2).y/3, (uv0 + uv1 + uv2).z/3])
12 color = tga.getpixel((int(uv.x * tga.width), tga.height - 1 - int(uv.y * tga.height)))
13 color = (int(color[0] * intensity), int(color[1] * intensity), int(color[2] * intensity))
14
15 # 遍历包围盒内的每个像素
16 for P.y in range(min_y, max_y + 1):
17 for P.x in range(min_x, max_x + 1):
18 # 计算当前像素的重心坐标
19 bc_screen = barycentric(p0, p1, p2, P)
20 if bc_screen is None:
21 continue
22 # 如果像素的重心坐标的任何一个分量小于0,那么这个像素就在三角形的外部,我们就跳过它
23 if bc_screen.x < 0 or bc_screen.y < 0 or bc_screen.z < 0:
24 continue
25
26 # 计算当前像素的深度
27 z = p0.z * bc_screen.x + p1.z * bc_screen.y + p2.z * bc_screen.z
28
29 # 检查Z缓冲区,如果当前像素的深度比Z缓冲区中的值更近,那么就更新Z缓冲区的值,并绘制像素
30 idx = P.x + P.y * img.width
31 if z_buffer[idx] < z:
32 z_buffer[idx] = z
33 image.putpixel((P.x, P.y), color)
Gouraud Shading
因为需要用到定点法线,所以新增了normals,以及faces中存储了顶点法线的索引,obj.py 做如下改动:
1from vector import Vec3
2
3
4class OBJFile:
5 def __init__(self, filename):
6 self.filename = filename
7 self.vertices = []
8 self.normals = [] # 添加一个新的列表来存储法线
9 self.texture_coords = [] # 添加一个新的列表来存储纹理坐标
10 self.faces = []
11
12 def parse(self):
13 with open(self.filename, 'r') as file:
14 for line in file:
15 components = line.strip().split()
16 if len(components) > 0:
17 if components[0] == 'v':
18 self.vertices.append([float(coord) for coord in components[1:]])
19 elif components[0] == 'vt': # 添加一个新的条件来处理纹理坐标
20 self.texture_coords.append([float(coord) for coord in components[1:]])
21 elif components[0] == 'vn': # 添加一个新的条件来处理法线
22 self.normals.append([float(coord) for coord in components[1:]])
23 elif components[0] == 'f':
24 # 修改这里,以便同时存储顶点、纹理坐标和法线的索引
25 self.faces.append([[int(index.split('/')[0]),
26 int(index.split('/')[1]),
27 int(index.split('/')[2])] for index in components[1:]])
28
29 # 添加一个新的方法来获取法线
30 def norm(self, index):
31 return Vec3(self.normals[index - 1])
32
33 def vert(self, i):
34 """
35 :param i: vertex index
36 :param i: 因为obj文件的顶点索引是从1开始的,所以需要减1
37 :return:
38 """
39 return Vec3(self.vertices[i - 1])
40
41 def texcoord(self, i): # 添加一个新的方法来获取纹理坐标
42 """
43 :param i: texture coordinate index
44 :param i: 因为obj文件的纹理坐标索引是从1开始的,所以需要减1
45 :return:
46 """
47 return Vec3(self.texture_coords[i - 1])
此时顶点法线信息有了,现在可以在三角形内部进行插值,以产生渐变效果:
1def triangle(p0: Vec3, p1: Vec3, p2: Vec3,
2 uv0: Vec2, uv1: Vec2, uv2: Vec2,
3 intensity0, intensity1, intensity2,
4 img: MyImage, tga: Image):
5 min_x = max(0, min(p0.x, p1.x, p2.x))
6 max_x = min(img.width - 1, max(p0.x, p1.x, p2.x))
7 min_y = max(0, min(p0.y, p1.y, p2.y))
8 max_y = min(img.height - 1, max(p0.y, p1.y, p2.y))
9 P = Vec2((0, 0))
10
11 # 遍历包围盒内的每个像素
12 for P.y in range(min_y, max_y + 1):
13 for P.x in range(min_x, max_x + 1):
14 # 计算当前像素的重心坐标
15 bc_screen = barycentric(p0, p1, p2, P)
16 if bc_screen is None:
17 continue
18 # 如果像素的重心坐标的任何一个分量小于0,那么这个像素就在三角形的外部,我们就跳过它
19 if bc_screen.x < 0 or bc_screen.y < 0 or bc_screen.z < 0:
20 continue
21
22 uv = uv0 * bc_screen.x + uv1 * bc_screen.y + uv2 * bc_screen.z
23 color = tga.getpixel((int(uv.x * tga.width), tga.height - 1 - int(uv.y * tga.height)))
24
25 # 插值光照强度
26 intensity = intensity0 * bc_screen.x + intensity1 * bc_screen.y + intensity2 * bc_screen.z
27 color = (int(color[0] * intensity), int(color[1] * intensity), int(color[2] * intensity))
28
29 # 计算当前像素的深度
30 z = p0.z * bc_screen.x + p1.z * bc_screen.y + p2.z * bc_screen.z
31
32 # 检查Z缓冲区,如果当前像素的深度比Z缓冲区中的值更近,那么就更新Z缓冲区的值,并绘制像素
33 idx = P.x + P.y * img.width
34 if z_buffer[idx] < z:
35 z_buffer[idx] = z
36 image.putpixel((P.x, P.y), color)
37
38
39# ... 其他相同代码省略
40
41if __name__ == '__main__':
42 width = 1200
43 height = 1200
44 depth = 255
45
46 tga: Image = Image.open('african_head_diffuse.tga')
47
48 image = MyImage((width, height))
49
50 z_buffer = [-sys.maxsize - 1] * width * height
51
52 obj = OBJFile('african_head.obj')
53 obj.parse()
54
55 model_ = model_matrix()
56 view_ = view_matrix(camera)
57 projection_ = projection_matrix()
58 viewport_ = viewport_matrix(width / 8, height / 8, width * 3 / 4, height * 3 / 4, depth)
59
60 light_dir = Vec3([0, 0, 1])
61 gamma = 2.2
62 for face in obj.faces:
63 screen_coords = [None, None, None] # 第i个面片三个顶点的屏幕坐标
64 world_coords = [None, None, None] # 第i个面片三个顶点的世界坐标
65 uv_coords = [None, None, None]
66 intensities = [0, 0, 0]
67 for j in range(3):
68 v: Vec3 = obj.vert(face[j][0])
69 world_coords[j] = v
70 uv_coords[j] = obj.texcoord(face[j][1]) # 获取纹理坐标
71
72 screen_coords[j] = homo_2_vertices(viewport_ * projection_division(
73 projection_ * view_ * model_ * local_2_homo(v)))
74
75 n: Vec3 = obj.norm(face[j][2]) # 使用顶点法线
76 n.normalize()
77 intensities[j] = max(0, n * light_dir)
78 triangle(screen_coords[0], screen_coords[1], screen_coords[2],
79 uv_coords[0], uv_coords[1], uv_coords[2],
80 intensities[0], intensities[1], intensities[2], image, tga)
81
82 image.save('Gouraud_Shading.bmp')
Phong Shading
添加了三个新的参数 n0
,n1
和 n2
,这些参数是每个顶点的法线。然后我们在每个像素中插值这些法线,就像我们插值纹理坐标一样。最后使用插值后的法线来计算光照强度,并使用这个光照强度来调整像素的颜色。但是这个实现只考虑了漫反射光照,没有考虑环境光和镜面反射。如果要实现一个更完整的 Phong Shading,需要添加环境光和镜面反射的计算,这里暂时就不考虑了。
1def triangle(p0: Vec3, p1: Vec3, p2: Vec3,
2 uv0: Vec2, uv1: Vec2, uv2: Vec2,
3 n0: Vec3, n1: Vec3, n2: Vec3,
4 img: MyImage, tga: Image):
5 min_x = max(0, min(p0.x, p1.x, p2.x))
6 max_x = min(img.width - 1, max(p0.x, p1.x, p2.x))
7 min_y = max(0, min(p0.y, p1.y, p2.y))
8 max_y = min(img.height - 1, max(p0.y, p1.y, p2.y))
9 P = Vec2((0, 0))
10
11 # 遍历包围盒内的每个像素
12 for P.y in range(min_y, max_y + 1):
13 for P.x in range(min_x, max_x + 1):
14 # 计算当前像素的重心坐标
15 bc_screen = barycentric(p0, p1, p2, P)
16 if bc_screen is None:
17 continue
18 # 如果像素的重心坐标的任何一个分量小于0,那么这个像素就在三角形的外部,我们就跳过它
19 if bc_screen.x < 0 or bc_screen.y < 0 or bc_screen.z < 0:
20 continue
21
22 uv = uv0 * bc_screen.x + uv1 * bc_screen.y + uv2 * bc_screen.z
23 color = tga.getpixel((int(uv.x * tga.width), tga.height - 1 - int(uv.y * tga.height)))
24
25 # 插值法线
26 n = n0 * bc_screen.x + n1 * bc_screen.y + n2 * bc_screen.z
27 n.normalize() # 正规化法线
28
29 # 计算光照强度
30 intensity = max(0, n * light_dir)
31 color = (int(color[0] * intensity), int(color[1] * intensity), int(color[2] * intensity))
32
33 z = p0.z * bc_screen.x + p1.z * bc_screen.y + p2.z * bc_screen.z
34 # 检查Z缓冲区,如果当前像素的深度比Z缓冲区中的值更近,那么就更新Z缓冲区的值,并绘制像素
35 idx = P.x + P.y * img.width
36 if z_buffer[idx] < z:
37 z_buffer[idx] = z
38 image.putpixel((P.x, P.y), color)
39
40# ... 其他相同代码省略
41
42if __name__ == '__main__':
43 width = 1200
44 height = 1200
45 depth = 255
46
47 tga: Image = Image.open('african_head_diffuse.tga')
48
49 image = MyImage((width, height))
50
51 z_buffer = [-sys.maxsize - 1] * width * height
52
53 obj = OBJFile('african_head.obj')
54 obj.parse()
55
56 model_ = model_matrix()
57 view_ = view_matrix(camera)
58 projection_ = projection_matrix()
59 viewport_ = viewport_matrix(width / 8, height / 8, width * 3 / 4, height * 3 / 4, depth)
60
61 light_dir = Vec3([0, 0, 1])
62 gamma = 2.2
63 for face in obj.faces:
64 screen_coords = [None, None, None] # 第i个面片三个顶点的屏幕坐标
65 world_coords = [None, None, None] # 第i个面片三个顶点的世界坐标
66 uv_coords = [None, None, None]
67 norms = [None, None, None]
68 for j in range(3):
69 v: Vec3 = obj.vert(face[j][0])
70 world_coords[j] = v
71 uv_coords[j] = obj.texcoord(face[j][1]) # 获取纹理坐标
72
73 screen_coords[j] = homo_2_vertices(viewport_ * projection_division(
74 projection_ * view_ * model_ * local_2_homo(v)))
75
76 n: Vec3 = obj.norm(face[j][2]) # 使用顶点法线
77 norms[j] = n
78 triangle(screen_coords[0], screen_coords[1], screen_coords[2],
79 uv_coords[0], uv_coords[1], uv_coords[2],
80 norms[0], norms[1], norms[2], image, tga)
81
82 image.save('Phong_Shading.bmp')
下面可以看看三种方式的对比:
本节就先这样,代码我放在这里了: https://github.com/zouchanglin/PyTinyRenderer