用smooth shading模拟flat shading的一种特殊技巧

这是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.8,我们知道这可能是2和3插值80%的结果,只要选择2或者3的颜色(至于哪一个后面讨论),那么三角形的所有中间像素都可以还原成纯色了。但这个方法只支持相邻颜色之间的着色,跨颜色的话就有二义性了:刚才的2.8还有可能是1和4插值60%的结果。

一维不行,那么二维呢?

(一不小心画成了六边形,假装它是五边形吧)

我们可以将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。

(之所以用这么奇怪的方法,是因为fragment shader拿到的只有插值后的结果,没有先验信息,我们只能用“是否变化”来描述状态,可以联想数字电路中的差分曼切斯特编码)

数学原理已经比较清楚了,最后还剩一个具体实现的问题:如何高效地判断一个像素编码在哪一个线段上?

注意到空间顶点的分布是比较有规律的,我们可以再引入一个编码系统,用于将连续的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的做法,其实更常见于平面着色计算模型,一般用于风格化渲染,顶点法线插值之后需要再还原成面法线。一个比较通用的方案是借助shader内置的dFdx()和dFdy()函数,可以参考这里。但这个方法不能套用到本文的顶点色还原问题。

最后值得一提的是,虽然OpenGL ES 2.0和WebGL 1都不支持flat shading,但是最近开始占据主流的OpenGL ES 3.2+和WebGL 2都支持了,因此本文方法仅供消遣娱乐,实用价值可以忽略。

posted @ 2020-11-10 00:30  Xrst  阅读(514)  评论(0编辑  收藏  举报