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

接下来,如果线段是从右向左绘制的,我们交换两个端点的坐标,使得线段总是从左向右绘制。这是为了简化后面的循环逻辑。

1if x0 > x1:  # 确保线段是从左往右绘制
2    x0, x1 = x1, x0
3    y0, y1 = y1, y0

然后,我们计算出线段在x方向和y方向的距离,以及线段的斜率(乘以2,这是为了避免使用浮点数运算)。

1dx = abs(x1 - x0)
2dy = abs(y1 - y0)
3slope = 2 * dy

初始化一个变量step,用来在后面的循环中累积误差。

1step = 0

确定y方向的增量,如果y1大于y0,那么y_incr为1,否则为-1。

1y = y0
2y_incr = 1 if y1 > y0 else -1

接下来,根据steep的值,选择不同的绘制方法。如果steepTrue,那么我们在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

Reference

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