• 用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.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都支持了,因此本文方法仅供消遣娱乐,实用价值可以忽略。

  • 相关阅读:
    [机器学习]决策树
    [机器学习]Bagging and Boosting
    [机器学习]SVM原理
    [算法]排序算法综述
    天目山大峡谷和西天目山游记【转】
    西天目山出游攻略
    京东物流深度研究报告:京东物流VS亚马逊物流VS顺丰
    「数据标签体系」中台价值链路中“核心的核心”
    网络空间安全概论第一、四章笔记
    21牛客多校第一场
  • 原文地址:https://www.cnblogs.com/xrst/p/13951836.html
Copyright © 2020-2023  润新知