两个着色器,以及它们中间
目标
用 Python 函数编写顶点着色器和片段着色器,在两者之间放入光栅化器,真正画出图像。完成本实验后,阅读 GLSL 代码时,你就能看清每个值从哪里来、到哪里去。
为什么重要
着色器并不是在流水线的任意位置开放的。流水线结构保持不变,只开放了顶点阶段和片段阶段。这样,其余部分(光栅化、深度判定、混合)才能继续由硬件并行处理。
这个限制决定了着色器的规则。顶点着色器不能创建或删除顶点,片段着色器看不到相邻像素的结果。作为补偿,两个阶段之间的通道十分明确——顶点着色器通过 varying 输出,光栅化器用重心坐标混合后交给片段着色器。亲手搭建一遍这种结构,就能明白 GPU 编程的限制为什么是这个样子。
步骤
- 在
/root/stages中放置工具箱。 /root/stages/vs.py— 顶点着色器。/root/stages/varying.py— 值的插值。/root/stages/fs.py和pipeline.py— 片段着色器与out/uv.png。/root/stages/out/u1.png、u2.png、u3.png— 只改变 uniform。/root/stages/out/discard.png— 丢弃片段。/root/stages/out/final.png和out/07-counts.txt— 调用次数。
参考
- 运行
nohup python3 -m http.server 8080 -d /root/stages/out &后,在网页预览中打开http://localhost:8080/。 - 绘制一张图需要几秒钟,因为片段着色器要被调用两万多次,而这种缓慢本身就是本实验要观察的内容之一。
- 常见错误 1:不对
varyings插值,而是直接使用第一个顶点的值。这样三角形会被涂成同一种颜色。 - 常见错误 2:转换为屏幕坐标时漏掉 y 翻转。图像会上下颠倒。
放置绘图工具箱
按示例原样保存 /root/stages/gfxlib.py,并用 /root/stages/check.py 绘制测试图案,生成 /root/stages/out/00-check.png。图案是在 64x64 黑色背景上,从 (0,0) 到 (63,63) 绘制白色(255,255,255) 对角线,再叠加从 (0,32) 到 (63,32) 的红色(255,0,0) 水平线。
从本实验开始不再重新实现 PNG 编码器。我们把第一个实验中手写的同类代码作为工具提供,因为这里要学习的不是文件格式。
实验 Pod 没有卷,上一个实验创建的文件不会保留。因此每个实验都从重新放置工具箱开始。
创建 Canvas(w, h, bg),用 line(x0, y0, x1, y1, rgb) 绘制两条线,再用 write_png(path) 保存。必须最后绘制水平线,交点 (32,32) 才会是红色。
本实验中用它把着色器生成的像素输出为图像。
顶点着色器
在 /root/stages/vs.py 中编写 vertex_shader(attr, uniforms)。attr 是 {"position": (x,y,z), "uv": (u,v)},uniforms 是 {"mvp": 4x4 행렬, "scale": 실수}(占位符依次为 4x4 矩阵与实数)。把位置乘以 scale 后应用 mvp,得到的四个分量作为 gl_Position,原样装入 uv 的字典作为 varyings,返回 {"gl_Position": ..., "varyings": {...}}。
顶点着色器接收一个顶点,输出一个顶点。不能创建或删除顶点。这样硬件才能预先知道要处理多少个,并并行分配。
attribute 是因顶点而异的值,uniform 是在整个绘制调用中保持不变的值。GPU 据此决定把值放在哪里。
矩阵乘法只需对 r=0..3 计算 m[r][0]*x + m[r][1]*y + m[r][2]*z + m[r][3]*1。因为是点,所以 w 为 1。
varyings 是要传给片段阶段的值。这里只有一个 uv,但在真实的着色器中,法线、颜色、切线等会一起携带。
光栅化器混合值
在 /root/stages/varying.py 中编写 interpolate(bary, v0, v1, v2)。bary 是 (u, v, w) 三个实数,v0、v1、v2 是键相同的 varyings 字典。值为元组时逐分量计算,为实数时直接计算 u*v0 + v*v1 + w*v2,返回形状相同的字典。
这一步属于光栅化器,而不是着色器。它是不可编程的固定阶段,我们之所以亲手实现,是因为顶点着色器的输出正是从这里通向片段着色器的输入。
需要区分值的类型,用 isinstance(x, (tuple, list)) 判断即可。
重心坐标之和为 1,所以三个顶点的值都相同时,结果也是同一个值。可以用这个性质检验实现。
片段着色器与第一张图
在 /root/stages/fs.py 中编写 fragment_shader(varyings, uniforms)。令 u, v = varyings["uv"]、k = uniforms["brightness"],返回 (255*(0.2+0.8*u)*k, 255*(0.2+0.8*v)*k, 255*(0.2+0.8*(1-u))*k)。然后用 /root/stages/pipeline.py 绘制三个顶点 (-0.8,-0.8,0)/uv(0,0)、(0.8,-0.8,0)/uv(1,0)、(-0.8,0.8,0)/uv(0,1),mvp 为单位矩阵,scale 为 1,brightness 为 1,生成 /root/stages/out/uv.png(256x256,黑色背景)。屏幕坐标为 sx=(x*0.5+0.5)*256、sy=(1-(y*0.5+0.5))*256。
流水线的顺序如下。对每个顶点调用顶点着色器,把裁剪坐标转换为屏幕坐标,遍历三角形的包围盒并求重心坐标,位于内部时对 varying 插值,再调用片段着色器。
片段着色器每个像素都会被调用。这个三角形覆盖了将近三分之一的屏幕,所以会被调用两万多次,而顶点着色器只有三次。这个差距是性能讨论的出发点。
颜色计算中加上 0.2,是为了让 uv 为 0 的位置颜色也不会变成 0。下一步改变亮度做对比时会用到。
gl_Position 的 w 为 1,所以这里的除法等同于恒等操作。加入透视后,这个除法才会真正起作用。
只改变 uniform
保持几何不变,只把 brightness 依次改为 1.0、0.6、0.3,生成 /root/stages/out/u1.png、u2.png、u3.png 三张图。
顶点数据和三角形都保持不变,只改变一个 uniform。
三张图中被着色的位置(非黑色像素的集合)必须完全相同。因为几何没有变化。如果有差异,说明顶点着色器或光栅化器受到了 uniform 的影响。
而颜色应当有明显的不同。这就是 uniform 的用途——用不同材质或不同光照多次绘制同一个网格时,无需重新发送顶点数据,只改变 uniform 即可。
颜色计算中加了 0.2,所以亮度为 0.3 时像素也不会变成黑色。
丢弃片段
绘制同一个三角形,但对插值得到的 uv 满足 u*u + v*v > 0.36 的片段执行丢弃,生成 /root/stages/out/discard.png(brightness 为 1.0)。
丢弃只有片段着色器才能做。声明完全不使用这个片段后,颜色缓冲和深度缓冲中都不会留下任何内容。
用来表现树叶或铁丝网这类有孔洞的表面。它与半透明不同——半透明是把后面的内容混合进来使其透出,而丢弃是在该位置什么也不画。
丢弃并不是免费的。含有 discard 的着色器必须计算出颜色才知道片段能否保留,因此无法使用提前判定深度的优化。所以有孔洞的表面比完全不透明的表面更贵。
被丢弃的位置会保留背景(黑色)。
各阶段分别被调用了多少次
在开启丢弃的状态下重新绘制并生成 /root/stages/out/final.png,同时在 /root/stages/out/07-counts.txt 中写入 vertices=、triangles=、fragments_in=、fragments_out= 四行。fragments_in 是进入三角形内部的片段数,fragments_out 是丢弃后剩下的数量。
顶点着色器的调用次数等于顶点数,片段着色器的调用次数等于屏幕被覆盖的面积。这个三角形只有三个顶点,片段却有两万多个。
所以应尽可能把繁重的计算移到顶点着色器,交给插值处理。不过插值是线性的,需要归一化的值必须在片段一侧重新归一化。
fragments_out 必须与最终图像中非黑色像素的个数完全一致。评分器会打开图像统计并核对。如果不一致,说明其中一方有误。
不要凭空编造数字,要在循环内部计数。