Unity Shader 极简实践6——用片元着色器画简单笑脸

白鹿

这片文章记录如何使用片原元着色器绘制一个最简单的笑脸。
首先看看最终绘制的效果图如下


笑脸

1. 思路

先整体绘制一个大的灰色圆形作为脸部,然后脸的内部将眼睛和嘴巴部分绘制成黄色即可。

  • 眼睛的绘制:选取某个点为圆心,其它与这个点距离小于半径点片元都绘制成为黄色
                float len1 = length(i.uv - _LeftEyeCenter.xy);
                float len2 = length(i.uv - _RightEyeCenter.xy);
                // 绘制眼睛
                if (len1 < _EyeRadio || len2 < _EyeRadio) {
                    resultColor = yellow;
                }
  • 嘴巴的绘制:选取两个原点和半径都不同都圆,当位置在两个圆之间的片元,都是嘴巴部分,绘制为黄色
                float len4 = length(i.uv - _MouthInCenter.xy);
                float len5 = length(i.uv - _MouthOutCenter.xy);
               else if (len4 < _MouthInSize && len5 > _MouthOutSize) {
                    resultColor = yellow;
                }
  • 脸部绘制:整个大圆中,除了眼睛和嘴巴,其它片元绘制为灰色即可

2. 第一版完整代码

Shader "Unlit/Face"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _EyeRadio ("Eye Size", float) = 0.15
        _MouthOutCenter ("MouthOuterCenter", Vector) =(0.5, 0.8, 0, 0)
        _MouthInCenter ("MouthInCenter", Vector) = (0.5, 0.5, 0, 0)
        _MouthOutSize ("MouthOutSize", float) = 0.5
        _MouthInSize ("MouthInSize", float) = 0.3
        _LeftEyeCenter ("LeftEyeCenter", Vector) = (0.3, 0.7, 0, 0)
        _RightEyeCenter ("RightEyeCenter", Vector) = (0.7, 0.7, 0, 0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            // 眼睛中心
            float4 _LeftEyeCenter;
            float4 _RightEyeCenter;
            // 眼睛半径
            float _EyeRadio;

            // 嘴巴外圈半径
            float _MouthOutSize;
            // 嘴巴内圈半径
            float _MouthInSize;

            // 嘴巴外圈圆心
            float4 _MouthOutCenter;
            // 嘴巴内圈圆心
            float4 _MouthInCenter;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 yellow = fixed4(1, 1, 0.0, 1);
                fixed4 gray = fixed4(0.75, 0.75, 0.75, 1);
                fixed4 white = fixed4(1, 1, 1, 0);
                fixed4 red = fixed4(0.6, 0, 0, 1);

                fixed4 resultColor;
                
                float len3 = length(i.uv - 0.5);

                float len4 = length(i.uv - _MouthInCenter.xy);
                float len5 = length(i.uv - _MouthOutCenter.xy);

                float len1 = length(i.uv - _LeftEyeCenter.xy);
                float len2 = length(i.uv - _RightEyeCenter.xy);

                // 绘制眼睛
                if (len1 < _EyeRadio || len2 < _EyeRadio) {
                    resultColor = yellow;
                }

                // 绘制嘴巴
                else if (len4 < _MouthInSize && len5 > _MouthOutSize) {
                    resultColor = yellow;
                }

                // 绘制圆脸
                else if (len3 < 0.5) {
                    resultColor = gray;
                }

                else {
                    resultColor = white;
                }
                return resultColor;
            }

            ENDCG
        }
    }
}

3. 代码优化

上面贴出来的代码成功绘制了笑脸,但代码里用到了很多if,在编写着色器代码(包括 Unity ShaderLab,CG、HLSL)时,一个无脑遵循的原则是

尽量不要使用 if for 等条件判断和循环的语法

原因大概是因为 GPU 和 CPU 的结构差异,GPU 中并没有那么多的控制寄存器,导致GPU在进行分支控制计算时需要以lockstep的方式进行,这会导致线程束的等待,耗费 GPU 周期从而导致性能下降,可以具体参考这篇文章从GPU硬件架构看渲染流水线,尽管现代的 GPU 针对架构进行了优化,iffor的消耗已经没有那么大了,但我们还是希望尽可能进行一些优化。那么我们需要考虑如何把上述代码中的 if 优化掉。

优化 if 最常用的方法是配合使用 stepclamp 内置方法。

clamp不用说了,在Unity Shader 极简实践3——step,lerp 和 smoothstep 应用这篇文章中有介绍关于内置方法 step 的用法,简单来说就是

step (a, x)
{
  if (x < a) 
  {
    return 0;
  }
  else
  {
    return 1;
  }
}

那么如下的这个 if 语句

               if (len3 < 0.5) {
                    resultColor = gray;
                }

便可以使用 step 进行这样的优化

resultColor = step(len3, 0.5) * gray; 

使用这样的方法,可以将上述的片元着色器优化为如下代码

            fixed4 frag(v2f i) : SV_Target
            {
                fixed4 yellow = fixed4(1, 1, 0.0, 1);
                fixed4 gray = fixed4(0.75, 0.75, 0.75, 1);
                fixed4 white = fixed4(1, 1, 1, 0);
                float len3 = length(i.uv - 0.5);
                float len4 = length(i.uv - _MouthInCenter.xy);
                float len5 = length(i.uv - _MouthOutCenter.xy);
                float len1 = length(i.uv - _LeftEyeCenter.xy);
                float len2 = length(i.uv - _RightEyeCenter.xy);

                // eye 返回1 则是眼睛范围
                float v = step(len1, _EyeRadio) + step(len2, _EyeRadio);
                float eye = clamp(v, 0, 1);

                // mouth 返回1 则是嘴巴范围
                float mouth = step(2, step(len4, _MouthInSize) + step(_MouthOutSize, len5));

                float eyeOrMouth = clamp(eye + mouth, 0, 1);

                // face 返回1 是脸的范围,包括嘴巴和眼睛
                float face = step(len3, 0.5);

                // 非脸的范围返回 white
                // 脸的范围内若是嘴巴或眼睛返回 yellow,否则返回 gray
                return face * (eyeOrMouth * yellow + (1 - eyeOrMouth) * gray) + (1 - face) * white;
            }

很明显,上述的return语句可以使用 lerp内置方法来进行如下的优化

return lerp(lerp(white, gray, face), yellow, eyeOrMouth);

4. 代码进一步优化

在这一部分我们将之前的片元着色器代码做一下抽象,考虑到绘制的眼睛、嘴巴都是基于绘制圆形来完成,我们抽取一个绘制圆形的方法

            /*
             * uv 片元的 uv
             * center 圆心
             * radio 半径
             */
            float circle(float2 uv, float2 center, float radio) {
                float len = length(uv - center);
                return step(len, radio);
            }

使用这个方法,在center为圆心,radio为半径的圆内部的片元,将返回1,否则返回0,那么使用这个返回值和圆的颜色相乘就绘制了对应的圆,如果将片元着色器修改为

            fixed4 frag(v2f i) : SV_TARGET
            {
                fixed4 yellow = fixed4(0.5, 0.5, 0, 1);
                return circle(i.uv, _LeftEyeCenter, _EyeRadio) * yellow;
            }

绘制左眼的圆形,得到的效果图如下

绘制左眼

提取了 circle 方法后,我们可以考虑针对它的返回值进行计算,在圆中的片元返回1,那么可以考虑两个圆相加的结果是

  • 当两个圆相交时,相交部分的片元返回2
  • 分别只在其中一个圆内的片元返回1
  • 不在任何圆中的片元返回0
    将片元着色器修改为
            // 不相交的圆相加
            fixed4 frag(v2f i) : SV_TARGET
            {
                fixed4 yellow = fixed4(0.5, 0.5, 0, 1);
                float eye = circle(i.uv, _LeftEyeCenter, _EyeRadio) + circle(i.uv, _RightEyeCenter, _EyeRadio);
                return eye * yellow;
            }

绘制结果如下图

不相交的圆相加

这里之所以把 yellow 这个颜色设置为一般的黄色,是需要更好展示圆形相交时的情况,我们将片元着色器做如下修改,将两个圆的半径增加,使得他们有部分相交

            // 相交的圆相加
            fixed4 frag(v2f i) : SV_TARGET {
                fixed4 yellow = fixed4(0.5, 0.5, 0, 1);
                float eye = circle(i.uv, _LeftEyeCenter, _EyeRadio * 3) + circle(i.uv, _RightEyeCenter, _EyeRadio * 3);
                return eye * yellow;
            }

可以看看绘制的结果:

相交的圆相加

我们可以看到,在两个圆相交的部分,黄色增强了,这是因为 circle 这个方法在相交部分的片元返回值为2,如果我们不想要增强颜色,相交部分也使用同样的颜色时,使用clamp来进行就可以了,将上述代码做简单修改:

return clamp(eye, 0, 1) * yellow;

修改后的绘制结果是这样的

clamp相交

我们再考虑一下两个circle相减会是什么结果,考虑 a - b

  • 返回 -1 和 返回 0 是一样的结果
  • a - b相当于先绘制a,再把b所占部分扣掉
    如果把片段着色器修改为如下代码:
            // 不相交的圆相减
            fixed4 frag(v2f i) : SV_TARGET
            {
                fixed4 yellow = fixed4(0.5, 0.5, 0, 1);
                float v = circle(i.uv, _LeftEyeCenter, _EyeRadio) - circle(i.uv, _RightEyeCenter, _EyeRadio);
                return v * yellow;
            }

绘制的结果是:


圆相减

修改片元着色器代码如下,调整半径,使得两个圆相交

            // 相交的圆相减
            fixed4 frag(v2f i) : SV_TARGET {
                fixed4 yellow = fixed4(0.5, 0.5, 0, 1);
                float eye = circle(i.uv, _LeftEyeCenter, _EyeRadio * 3) - circle(i.uv, _RightEyeCenter, _EyeRadio * 3);
                return eye * yellow;
            }

得到的绘制结果是:

相交的圆相减

那么,在抽象了一个圆的绘制方法circle以及考虑了circle的加减运算结果后,可以将上述的笑脸绘制方法修改为如下代码:

            // 抽象 circle 来简化代码
            fixed4 frag(v2f i) : SV_TARGET
            {
                fixed4 yellow = fixed4(1, 1, 0.0, 1);
                fixed4 gray = fixed4(0.75, 0.75, 0.75, 1);
                fixed4 white = fixed4(1, 1, 1, 0);

                float eye = clamp(circle(i.uv, _LeftEyeCenter, _EyeRadio) + circle(i.uv, _RightEyeCenter, _EyeRadio), 0, 1);
                float mouth = clamp(circle(i.uv, _MouthInCenter, _MouthInSize) - circle(i.uv, _MouthOutCenter, _MouthOutSize), 0, 1);
                float face = clamp(circle(i.uv, float2(0.5, 0.5), 0.5), 0, 1);

                return lerp(white, lerp(gray, yellow, (eye + mouth)), face);
            }

得到的绘制结果如下,可以看到,跟优化前代码的绘制效果一摸一样


抽象后的代码绘制结果

关于最后一行代码

return lerp(white, lerp(gray, yellow, (eye + mouth)), face);

可以这么来理解:因为我们使用stepclamp确保了 eyemouthface 的取值都是 0 和 1,lerp针对1和0来处理其实就是if-else的效果了:

  • 针对外层 lerp,若片元不在脸face的范围,返回颜色 white,否则返回第二层lerp结果;
  • 第二层lerp 针对face的片元,若在眼睛 eye或嘴巴mouth的返回,返回颜色yellow,否则返回颜色gray;
  • 这里使用针对 0 和 1 的 lerp来替代 if-else

附:在测试时材质上的参数设置如下图,可以通过调整这些参数得到不同的笑脸效果


材质参数

你可能感兴趣的:(Unity Shader 极简实践6——用片元着色器画简单笑脸)