PyTinyRenderer软渲染器-01
TinyRenderer是一个从零开始用C++写一个光栅化渲染器,不依赖任何外部库,实现一个超级简化版的OpenGL。但是PyTinyRenderer相信你看名字也能知道,这是一个采用Python来实现的渲染器,当然和TinyRenderer一样,会尽量少的使用外部库。但是遇到类似TGA文件的读写,也可以使用到Pillow等库来进行操作,核心目的是了解渲染的工作流而已。
实现Image的操作库
我们需要先实现一个Image Lib,创建并保存图片,并且可以设置图片上的每一个像素。当然,效率优先的前提下直接使用pillow依赖库也是个不错的选择。
安装pillow库,这与TinyRenderer的tgaimage.cpp的作用是一样的,只不过直接生成PNG的图方便预览。
1pillow==10.3.0
1from PIL import Image
2
3image = Image.new('RGB', (50, 50))
4
5red = (255, 0, 0)
6green = (0, 255, 0)
7
8# 画两条线试试
9for i in range(0, 50):
10 image.putpixel((i, 25), red)
11 image.putpixel((10, i), green)
12
13image.save('output.png')
看得出来,需要用到的核心方法就是putpixel,用来设置一个像素点颜色,然后save保存即可:
哈哈,不是说不用外部库吗?其实我们也可以自己实现一个简单版本,虽然不是重点,但是如果真的零外部依赖也是可以做到的,下面给出一个实现方案,只不过是最基本的Bitmap样式,最核心的点在于Bitmap的编码,BMP文件格式规定了多字节值应该以little endian格式存储,这就是为什么在代码中使用了'little'
参数,因为使用的是小端字节序:
1class MyImage:
2 def __init__(self, size, color=(0, 0, 0)):
3 self.width, self.height = size # 定义图像的宽度和高度
4 self.data = [[color for _ in range(self.width)] for _ in range(self.height)] # 初始化图像数据
5
6 def putpixel(self, position, color):
7 x, y = position # 获取像素位置
8 if 0 <= x < self.width and 0 <= y < self.height: # 检查像素位置是否在图像范围内
9 self.data[y][x] = color # 设置像素颜色
10
11 def save(self, filename):
12 with open(filename, 'wb') as f:
13 # BMP文件头(14字节)
14 f.write(b'BM') # ID字段
15 f.write((14 + 40 + self.width * self.height * 3).to_bytes(4, 'little')) # 文件大小
16 f.write((0).to_bytes(2, 'little')) # 未使用
17 f.write((0).to_bytes(2, 'little')) # 未使用
18 f.write((14 + 40).to_bytes(4, 'little')) # 偏移至像素数据
19
20 # DIB头(40字节)
21 f.write((40).to_bytes(4, 'little')) # 头大小
22 f.write((self.width).to_bytes(4, 'little')) # 图像宽度
23 f.write((self.height).to_bytes(4, 'little')) # 图像高度
24 f.write((1).to_bytes(2, 'little')) # 颜色平面数量
25 f.write((24).to_bytes(2, 'little')) # 每像素位数
26 f.write((0).to_bytes(4, 'little')) # 压缩方法
27 f.write((self.width * self.height * 3).to_bytes(4, 'little')) # 图像大小
28 f.write((0).to_bytes(4, 'little')) # 水平分辨率
29 f.write((0).to_bytes(4, 'little')) # 垂直分辨率
30 f.write((0).to_bytes(4, 'little')) # 色彩板中的颜色数量
31 f.write((0).to_bytes(4, 'little')) # 重要颜色数量
32
33 # 像素数据
34 for y in range(self.height):
35 for x in range(self.width):
36 r, g, b = self.data[y][x] # 获取像素颜色
37 f.write(b.to_bytes(1, 'little')) # 蓝色
38 f.write(g.to_bytes(1, 'little')) # 绿色
39 f.write(r.to_bytes(1, 'little')) # 红色
40
41 # 填充至4字节
42 for _ in range((self.width * 3) % 4):
43 f.write(b'\x00') # 写入填充字节
44
45image = MyImage((50, 50)) # 创建一个新的图像实例
46
47red = (255, 0, 0) # 定义红色
48green = (0, 255, 0) # 定义绿色
49
50for i in range(0, 50):
51 image.putpixel((i, 25), red) # 在图像中添加红色像素
52 image.putpixel((10, i), green) # 在图像中添加绿色像素
53
54image.save('output.bmp') # 保存图像为BMP文件
Vector2、Vector3
在读取obj文件之前,我们还需要一些基础类,比如Vector2,Vector3,以此来定义三维向量的基本结构,添加一些常见的向量操作,如加法、减法、点积(内积)、叉积、长度(模),以及标准化等。另外Vector2二维向量没有叉积操作。
1import math
2
3
4class Vec2:
5 def __init__(self, values):
6 self.x, self.y = values
7
8 def __add__(self, other):
9 return Vec2([self.x + other.x, self.y + other.y])
10
11 def __sub__(self, other):
12 return Vec2([self.x - other.x, self.y - other.y])
13
14 def __mul__(self, other):
15 if isinstance(other, (int, float)): # 向量和标量的乘法
16 return Vec2([self.x * other, self.y * other])
17 elif isinstance(other, Vec2): # 两个向量的点积
18 return self.x * other.x + self.y * other.y
19
20 def __rmul__(self, scalar):
21 return self.__mul__(scalar)
22
23 def norm(self):
24 return math.sqrt(self.x * self.x + self.y * self.y)
25
26 def normalize(self, l=1):
27 norm = self.norm()
28 self.x *= l / norm
29 self.y *= l / norm
30 return self
31
32 def __str__(self):
33 return f'({self.x}, {self.y})'
34
35
36class Vec3:
37 def __init__(self, values):
38 self.x, self.y, self.z = values
39
40 def __add__(self, other):
41 return Vec3([self.x + other.x, self.y + other.y, self.z + other.z])
42
43 def __sub__(self, other):
44 return Vec3([self.x - other.x, self.y - other.y, self.z - other.z])
45
46 def __mul__(self, other):
47 if isinstance(other, (int, float)): # 向量和标量的乘法
48 return Vec3([self.x * other, self.y * other, self.z * other])
49 elif isinstance(other, Vec3): # 两个向量的点积
50 return self.x * other.x + self.y * other.y + self.z * other.z
51
52 def __rmul__(self, scalar):
53 return self.__mul__(scalar)
54
55 def cross(self, other):
56 return Vec3([self.y * other.z - self.z * other.y,
57 self.z * other.x - self.x * other.z,
58 self.x * other.y - self.y * other.x])
59
60 def norm(self):
61 return math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
62
63 def normalize(self, l=1):
64 norm = self.norm()
65 self.x *= l / norm
66 self.y *= l / norm
67 self.z *= l / norm
68 return self
69
70 def __str__(self):
71 return f'({self.x}, {self.y}, {self.z})'
实现obj文件的读取
.obj
文件是一种3D模型文件格式,我们需要理解 .obj
文件的格式,才能正确的读取。OBJ文件包含了3D几何形状的面、顶点和纹理映射信息。在OBJ文件中,v、vt、vn和f都是重要的元素,分别代表了顶点、纹理坐标、法向量和面。
v
:这是一个顶点的几何位置。它是一个三维坐标,表示为(x, y, z)
。例如,v 1.000 1.000 0.000
表示一个位于(1,1,0)
的顶点。
vt
:这是一个纹理坐标。它通常是一个二维坐标,表示为(u, v)
,用于映射3D模型的表面纹理。例如,vt 0.500 1.000
表示一个纹理坐标位于(0.5,1)
。
vn
:这是一个顶点的法向量。它是一个三维向量,表示为(x, y, z)
,用于计算光照和阴影效果。例如,vn 0.000 0.000 1.000
表示一个指向Z轴正方向的法向量。
f
:这是一个面,由三个或更多的顶点定义。面的定义通常包含顶点、纹理坐标和法向量的索引。例如,f 1/1/1 2/2/2 3/3/3
表示一个由第1、2、3个顶点、纹理坐标和法向量定义的三角形面。
在OBJ文件中,这些元素的索引是从1开始的,而不是从0开始。这意味着f 1/1/1 2/2/2 3/3/3
中的1、2、3实际上是指向第一个、第二个和第三个顶点、纹理坐标和法向量,而不是第0个、第1个和第2个。
由于目前我们只需要顶点和面的信息,所以先构造一个简单的读取obj文件的工具类对象:
1class OBJFile:
2 def __init__(self, filename):
3 self.filename = filename
4 self.vertices = []
5 self.faces = []
6
7 def parse(self):
8 with open(self.filename, 'r') as file:
9 for line in file:
10 components = line.strip().split()
11 if len(components) > 0:
12 if components[0] == 'v':
13 self.vertices.append([float(coord) for coord in components[1:]])
14 elif components[0] == 'f':
15 self.faces.append([int(index.split('/')[0]) for index in components[1:]])
16
17 def vert(self, i):
18 """
19 :param i: vertex index
20 :param i: 因为obj文件的顶点索引是从1开始的,所以需要减1
21 :return:
22 """
23 return Vec3(self.vertices[i - 1])
Bresenham线段绘制算法
上面的例子中展示了如何绘制直线,但是都是横竖的直线,现在来看看斜线怎么绘制。其实在之前的博客中:《直线光栅化DDA、Brensenham算法与三角形光栅化》,简单介绍过直线的绘制算法,这里再稍微复习复习Bresenham的线段算法。该算法是一个基于整数运算的高效线段绘制算法。
首先,定义函数line
,它接受两个点的坐标(x0, y0)
和(x1, y1)
,一个图像对象image
,和一个颜色color
作为参数。
1def line(x0, y0, x1, y1, image, color):
2 steep = False
3 if abs(x0 - x1) < abs(y0 - y1): # 如果线段很陡,我们转置图像
4 x0, y0 = y0, x0
5 x1, y1 = y1, x1
6 steep = True
7 if x0 > x1: # 确保线段是从左往右绘制
8 x0, x1 = x1, x0
9 y0, y1 = y1, y0
10
11 dx = abs(x1 - x0)
12 dy = abs(y1 - y0)
13 slope = 2 * dy
14 step = 0
15
16 y = y0
17
18 y_incr = 1 if y1 > y0 else -1
19 if steep:
20 for x in range(x0, x1 + 1):
21 image.putpixel((y, x), color)
22 step += slope
23 if step > dx:
24 y += y_incr
25 step -= 2 * dx
26 else:
27 for x in range(x0, x1 + 1):
28 image.putpixel((x, y), color)
29 step += slope
30 if step > dx:
31 y += y_incr
32 step -= 2 * dx
简单解释一下这段代码:
定义一个布尔变量steep
,用来表示线段的斜率是否大于1(是否陡峭)。如果线段的y方向的距离大于x方向的距离,那么就把线段进行转置,这是为了确保我们能正确地在任意斜率的情况下绘制线段。
1steep = False
2if abs(x0 - x1) < abs(y0 - y1): # 如果线段很陡,我们转置图像
3 x0, y0 = y0, x0
4 x1, y1 = y1, x1
5 steep = True
接下来,如果线段是从右向左绘制的,我们交换两个端点的坐标,使得线段总是从左向右绘制。这是为了简化后面的循环逻辑。
然后,我们计算出线段在x方向和y方向的距离,以及线段的斜率(乘以2,这是为了避免使用浮点数运算)。
初始化一个变量step
,用来在后面的循环中累积误差。
1step = 0
确定y方向的增量,如果y1
大于y0
,那么y_incr
为1,否则为-1。
接下来,根据steep
的值,选择不同的绘制方法。如果steep
为True
,那么我们在y方向上递增x,并在x方向上递增y。否则,我们在x方向上递增x,并在y方向上递增y。
1if steep:
2 for x in range(x0, x1 + 1):
3 image.putpixel((y, x), color)
4 step += slope
5 if step > dx:
6 y += y_incr
7 step -= 2 * dx
8else:
9 for x in range(x0, x1 + 1):
10 image.putpixel((x, y), color)
11 step += slope
12 if step > dx:
13 y += y_incr
14 step -= 2 * dx
在每次循环中,我们都会在当前的位置上画一个点,然后增加step
的值。如果step
大于dx
,那么我们就在y方向上递增(或递减)一步,并把step
减去2*dx
。这个过程就是Bresenham的线段算法的核心,它能够在整数运算的基础上准确地模拟出线段的形状。
现在我们尝试下这个方法:
1if __name__ == '__main__':
2 width = 200
3 height = 200
4 image = MyImage((width, height))
5 white = (255, 255, 255)
6 line(0, 0, 50, 150, image, white)
7 line(0, 90, 120, 50, image, white)
8 line(150, 100, 0, 110, image, white)
9 line(180, 20, 20, 50, image, white)
10 image.save('out.bmp')
通过绘制线段展示模型
我们这里仅仅是展示模型的网格,通过绘制线段的方式展示出来就OK,所以仅仅知道顶点和面的信息即可。其中需要将模型的将3D坐标转换为宽高设定值的Image内的2D像素坐标,这样才能保证模型展示是居中的。
1if __name__ == '__main__':
2 width = 800
3 height = 800
4 image = MyImage((width, height))
5 white = (255, 255, 255)
6 obj = OBJFile('african_head.obj')
7 obj.parse()
8
9 for face in obj.faces:
10 for j in range(3):
11 v0: Vec3 = obj.vert(face[j])
12 v1: Vec3 = obj.vert(face[(j + 1) % 3])
13 x0 = int((v0.x + 1) * width / 2)
14 y0 = int((v0.y + 1) * height / 2)
15 x1 = int((v1.x + 1) * width / 2)
16 y1 = int((v1.y + 1) * height / 2)
17 line(x0, y0, x1, y1, image, white)
18 image.save('out.bmp')
本文是关于如何用Python制作一个简化版的3D渲染器,展示了如何自己编写代码来生成简单的Bitmap图片,这样就不需要依赖任何外部库了,介绍了两个重要的类,Vector2和Vector3,这两个类用于处理2D和3D向量的计算,包括加法、减法、点积、叉积、长度和标准化等操作。然后记录了如何读取.obj文件、如何使用Bresenham线段绘制算法来在屏幕上绘制3D模型。
本节就先这样,代码我放在这里了: https://github.com/zouchanglin/PyTinyRenderer