这是N年前我在工作中遇到的一个问题。当时要实现OpenGL渲染路线上的颜色,即用不同颜色表示不同的拥堵状态。期望效果是这样的:
整条路线共12个顶点,一次draw call画出来,采用的是triangle strip(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)。颜色是顶点色,比如说顶点0、1、2、3是红色,4、5是绿色。
但是渲染管线对于三角形图元的颜色属性会自动进行插值,到了fragment shader中拿到的就是插值后的结果:
这不是希望的效果,特别是顶点之间有可能疏密不均,视觉效果就会很差。(不过后来我用了另一种技巧特意实现了均匀的渐变,这是另一个话题了)
要实现最初的那张图的效果,桌面版的OpenGL其实只需要做简单改动即可:将fragment shader中的color varying变量加上前缀修饰符flat即可:
flat in vec3 v_color;
这即是所谓的flat shading,而默认的有渐变的叫做smooth shading。
flat shading对于三角形的属性不会进行插值,例如顶点色为(红,红,绿)的三角形(2, 3, 4),flat shading始终会用最后一个顶点的颜色即绿色进行填充。
不过事情远没这么简单,我在上移动设备真机上调试的时候,才发现移动版的OpenGL ES 2.0并不支持flat shading,出不来这样的效果。
那么只能继续用smooth shading。
要解决问题,其实有个很简单的办法:顶点不共享即可,采用triangle list without index的方法,图元表示改成:(0, 1, 2) (2, 1, 3) (2, 3, 4)……这样会比原先的striangle strip多浪费些空间,比如顶点2重复了3次,但正因为重复了3次,它的颜色可以分别设置,最终实现无渐变的效果。
不过那时候比较爱惜内存,不打算采用此方案。我花了一些时间探索了编码格式,希望找到一种smooth shading下能模拟flat shading的方法。
因为路线上的颜色种类不多,一共只有5种,所以我们可以将颜色进行编码,传进shader,并传进去包含这5个颜色的调色板,然后在fragment shader中进行解码,结合调色板,还原出原颜色。
最容易想到的编码是一维的:5个颜色分别表示成数字0-4,在fragment shader中拿到插值后的数字例如2.7,我们知道这可能是2和3插值出来的,只要选择2或者3的颜色(至于哪一个后面讨论),那么三角形的所有中间像素都可以还原成纯色了。但这个方法只适合相邻颜色之间的着色,因为跨颜色的话就有二义性了:刚才的2.7还有可能是1和4插值出来的。
一维不行,那么二维呢?
我们可以将5个颜色编码成二维平面上的5个坐标(vec2),插值出来的像素的编码坐标都在橙色的线段上。
扩充到2维之后可以大幅度避免线段重复,但仍旧是不完美的,因为线段之间会有交点,还是有小概率重复。
于是来到了三维:
这下子终于没有重复了。(5个顶点之间的连接线我懒得画出来了,不过很容易脑补。)
如果不嫌麻烦,此方法可以扩展到任意多的颜色数量,因为三维空间中可以存在任意多的顶点,使得两两连线仅在端点处相交。
回到正题,将5个顶点分别编码为(0, 0, 0) (1, 0, 0) (0, 1, 0) (0, 0, 1) (1, 1, 1),在shader中这些3维坐标会进行插值,插值后的坐标一定在某个线段上。
我们只需要判断出某个像素在哪条线段上,那么就能知道是哪两个端点之间。
接着下一个问题来了:两个端点选择哪个?
为了体现颜色变化的方向性,除了xyz,我们还需要第四个维度w表示方向:对于某个像素,w为0或者1表示其中一个方向,非整数表示另一个方向。
对应原始的2个相邻顶点,w如果变化表示一个方向,w如果不变则表示另一个方向,而w的取值只有{0, 1}。
语言解释比较乏力,举个例子就比较容易理解了:
对于颜色1(1, 0, 0)和2(0, 1, 0)之间的插值:
1)正向(插值出2):两种颜色表示分别为(1, 0, 0, 0)和(0, 1, 0, 1),或者(1, 0, 0, 1)和(0, 1, 0, 0)
2)反向(插值出1):两种颜色表示分别为(1, 0, 0, 0)和(0, 1, 0, 0),或者(1, 0, 0, 1)和(0, 1, 0, 1)
某个像素如果w是小数,对应情况1;如果是整数则对应情况2。
(为什么要用小数/整数来划分两个方向,而不是0/非0等方式呢?这是因为在顶点数据流中,方向属性是有后效性的,只能用变化来描述状态,可以联想数字电路中的差分曼切斯特编码)
数学原理已经比较清楚了,最后还剩一个具体实现的问题:如何高效地判断一个像素编码在哪一个线段上?
注意到空间顶点的分布是比较有规律的,我们可以再引入一个编码系统,用于将连续的vec4(x, y, z, w)映射到一个中间code,这个中间code再查询一个字典转换成0-4的数字。这样可以避免shader中进行大量几何运算。
因为每个维度可以划分成0、1、小数三个状态,我们采用3进制来描述:
int code = (v_colorCode.r == 0.0 ? 0 : v_colorCode.r > 0.999 ? 1 : 2) + (v_colorCode.g == 0.0 ? 0 : v_colorCode.g > 0.999 ? 3 : 6) + (v_colorCode.b == 0.0 ? 0 : v_colorCode.b > 0.999 ? 9 : 18) + (v_colorCode.a == 0.0 ? 0 : v_colorCode.a > 0.999 ? 0 : 27);
(这里为什么用>0.999而不是==1.0我记不太清楚了,可能是为了避免某种精度误差)
rgb分别是xyz,各有三个状态;a是表示w的方向,只有两个状态。
另外我们还需要制作一个长度为54的字典:
uniform int u_dict[54];
再结合包含5个颜色的调色板:
uniform vec3 u_palette[5];
于是最终颜色rgb就可以通过二次索引计算出来了:
fragColor = vec4(u_palette[u_dict[code]], 1.0);
我用WebGL复现了这个做法,也提供了源码。Demo中分别对比了smooth shading、flat shading和本文所述模拟方法。注意需要支持WebGL 2的浏览器。
后记:
这个方法整体来说非常折腾,非常费解,以至于当时写完两个月后已经看不太懂了,另外这shader怎么看都效率堪忧,最终我还是改成了不共享顶点的triangle list,内存膨胀了些,但代码可读性至少提升了10个档次。
以smooth shading来模拟flat shading的做法,其实更常见于平面着色计算模型(真正的flat shading),顶点法线插值之后需要再还原成面法线。一个比较通用的方案是借助shader内置的dFdx()和dFdy()函数,可以参考这里。但这个方法不能套用到本文的顶点色还原问题。
最后值得一提的是,虽然OpenGL ES 2.0和WebGL 1都不支持flat shading,但是这两年开始占据主流的OpenGL ES 3.0+和WebGL 2都支持了,因此本文方法仅供消遣娱乐,实用价值可以忽略。