TT Lab
开始
学习 学习路径 课程

着色器与 GPU 管线

两个着色器,以及它们中间

在 TT Lab 中继续学习

目标

用 Python 函数编写顶点着色器和片段着色器,在两者之间放入光栅化器,真正画出图像。完成本实验后,阅读 GLSL 代码时,你就能看清每个值从哪里来、到哪里去。

为什么重要

着色器并不是在流水线的任意位置开放的。流水线结构保持不变,只开放了顶点阶段和片段阶段。这样,其余部分(光栅化、深度判定、混合)才能继续由硬件并行处理。

这个限制决定了着色器的规则。顶点着色器不能创建或删除顶点,片段着色器看不到相邻像素的结果。作为补偿,两个阶段之间的通道十分明确——顶点着色器通过 varying 输出,光栅化器用重心坐标混合后交给片段着色器。亲手搭建一遍这种结构,就能明白 GPU 编程的限制为什么是这个样子。

步骤

  1. 在 /root/stages 中放置工具箱。
  2. /root/stages/vs.py — 顶点着色器。
  3. /root/stages/varying.py — 值的插值。
  4. /root/stages/fs.py 和 pipeline.py — 片段着色器与 out/uv.png。
  5. /root/stages/out/u1.png、u2.png、u3.png — 只改变 uniform。
  6. /root/stages/out/discard.png — 丢弃片段。
  7. /root/stages/out/final.png 和 out/07-counts.txt — 调用次数。

参考

放置绘图工具箱

按示例原样保存 /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 必须与最终图像中非黑色像素的个数完全一致。评分器会打开图像统计并核对。如果不一致,说明其中一方有误。

不要凭空编造数字,要在循环内部计数。