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)是一个重要的概念,它决定了物体表面的颜色和亮度。常见以下三种:

  1. Flat Shading:平面着色是最简单的着色技术。在这种技术中,每个多边形(通常是三角形)都被赋予一个单一的颜色。这个颜色通常是由多边形的法线向量和光源的方向决定的。因为每个多边形只有一个颜色,所以结果通常会显得非常“平坦”,没有渐变效果。这种技术的优点是计算简单,但缺点是视觉效果较差。

  2. Gouraud Shading:高洛德着色是一种改进的着色技术。在这种技术中,每个顶点都被赋予一个颜色,然后在多边形内部进行插值,以产生渐变效果。这种技术的优点是可以产生较为平滑的视觉效果,但缺点是可能会出现“亮点”(即光源直接照射的地方)的渲染不准确。

  3. 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

添加了三个新的参数 n0n1n2,这些参数是每个顶点的法线。然后我们在每个像素中插值这些法线,就像我们插值纹理坐标一样。最后使用插值后的法线来计算光照强度,并使用这个光照强度来调整像素的颜色。但是这个实现只考虑了漫反射光照,没有考虑环境光和镜面反射。如果要实现一个更完整的 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

Reference

https://github.com/ssloy/tinyrenderer/wiki