返回
Featured image of post Shader入门精要-非真实渲染

Shader入门精要-非真实渲染

笔记摘自书籍《Shader入门精要》

卡通风格的渲染

原理

要实现卡通渲染有很多方法,其中之一就是使用基于色调的着色技术(tone-based shadding)。在实现中,我们往往会使用漫反射系数对一张一维纹理进行采样,以控制漫反射的色调。我们曾在前面使用渐变纹理实现这样的效果。卡通风格的高光效果和我们之前学习的光照不同。在卡通风格中,模型的高光往往是一块块分解明显的纯色区域。

除了光照模型之外,卡通风格通常还需要在物体边缘部分绘制轮廓。在之前的章节中,我们曾介绍使用屏幕后处理技术对屏幕图像进行描边,在本节,我们将会介绍基于模型的描边方法,这种方法的实现更加简单,而且在很多情况下也能得到不错的效果。本节结束后,我们将会实现类似下图的效果:

渲染轮廓线

在实时渲染中,轮廓线的渲染是应用非常广泛的一种效果。近20年来,有许多绘制模型轮廓线的方法被先后提出来。在《Real Time Rendering ,third edition》一书中,作者把这些方法分成了下面5种类型:

  1. 基于观察角度和表面法线的轮廓线渲染。这种方法使用视角方向和表面法线的点乘结果来得到轮廓线的信息。这种方法简单快速,可以在一个Pass中就得到渲染结果,但局限性很大,很多模型渲染出来的描边效果都不尽人意。

  2. 过程式几何轮廓线渲染。这种方法的核心是使用两个Pass渲染。第一个Pass渲染背面的面片,并使用某些技术让它的轮廓可见;第二个Pass再正常渲染正面的面片,这种方法的优点在于快速有效,并且适用于绝大多数表面平滑的模型,但它的缺点是不适合类似于立方体这样平整的模型。

  3. 图像处理的轮廓线渲染。我们在前面介绍的边缘检测的方法就属于这个类别。这种方法的优点在于,可以适用于任何种类的模型。但它也有自身的局限所在,一些深度和法线变化很小的轮廓无法被检测出来,例如桌子上的纸张。

  4. 轮廓边检测的轮廓线渲染。上面提到的各种方法。一个最大的问题是,无法控制轮廓线的风格渲染。对于一些情况,我们希望可以渲染出独特风格的轮廓线,例如水墨风格等。为此,我们希望可以检测出精准的轮廓边,然后直接渲染它们。检测一个边是否是轮廓边的公式很简单,我们只需要检测和这条边相邻的两个三角面片是否满足以下条件:

    ( n0 · v > 0 ) ≠ ( n1 · v > 0 )

    其中,n0和n1分别表示两个相邻三角面片的法向,v是从视角到该边上任意顶点的方向。上述公式的本质在于检查两个相邻的三角面片是否一个朝正面、一个朝背面,我们可以在几何着色器(Geometry Shader)的帮助下实现上面的检测过程。当然这种方法也有缺点,除了实现相对复杂外,它还会有动作连贯性问题。也就是说,由于是逐帧单独提取轮廓,所以在帧与帧之间会出现跳跃性。

  5. 一个种类就是混合了上述的几种渲染方法。例如首先找到精确的轮廓边,把模型和轮廓边渲染到纹理中,再使用图像处理的方法识别出轮廓线,并在图像空间下进行风格化渲染。

在本节中,我们将会在Unity中使用过程式几何轮廓线渲染的方法来对模型进行轮廓描边。我们将使用两个Pass渲染模型:

在第一个Pass中,我们会使用轮廓线颜色渲染整个背面的面片,并在视角空间下把模型顶点沿着法线方向向外扩张一段距离,以此来让背部轮廓线可见。代码如下:

viewNormal=viewPos+viewNormal*_Outline;

但是如果直接使用顶点法线进行扩展,对于一些内凹的模型,就可能发生背面面片遮挡正面面片的情况。

为了尽可能防止出现这种情况,在扩张背面顶点之前,我们首先对顶点法线的z分量进行处理,使它们等于一个定值,然后把法线归一化后再对顶点进行扩张。这样的好处在于,扩张后的背面更加扁平化,从而降低了遮挡正面面片的可能性。代码如下:

viewNormal.z=-0.5;
viewNormal=normalize(viewNormal);
viewPos=viewPos+viewNormal*_Outline;

添加高光

前面提到过,卡通风格的高光往往是模型上一块块分界明显的纯色区域。为了实现这种效果,我们就不能再使用之前学习的光照模型。回顾一下,在之前实现Blinn-Phong模型的过程中,我们使用法线点乘光照方向以及视角方向和的一半,再和另一个参数进行指数操作得到高光反射系数。代码如下:

float spec=pow(max(0,dot(normal,halfDir)),_Gloss)

对于卡通渲染需要的高光反射光照模型,我们同样需要计算normalhalfDir的点乘结果,但不同的是,我们把该值和一个阈值进行比较,如果小于该阈值,则高光反射系数为0,否则返回1。

float spec = dot(worldNormal,worldHalfDir);
spec = step(threshold,spec);

在上面的代码中,我们使用CG的step函数来实现和阈值比较的目的。step函数接收两个参数,第一个参数是参考值,第二个参数是待比较的数值。如果第二个参数大于等于第一个参数,则返回1,否则返回0。 但是,这种粗暴的判断方法会在高光区域的边界造成锯齿,如下图左边所示:

出现这种问题的原因在于,高光区域的边缘不是平滑渐变的,而是由0突变到1。要想对其进行抗锯齿处理,我们可以在边界处很小的一块区域内,进行平滑处理,代码如下:

float spec = dot (worldNormal,worldHalfDir);
spec = lerp(0,1,smoothstep(-w,w,spec-threshold));

在上面的函数中,我们没有像之前一样直接使用step函数返回0或1,而是首先使用了CG的smoothstep函数。其中w是一个很小的值,当spec-threshold小于-w时,返回0,大于w时,返回1,否则在0到1之间进行插值。这样的效果是,我们可以在[-w,w]区间内,即高光区域的边界处,得到一个从0到1平滑变化的spec值,从而实现抗锯齿的目的。尽管我们可以把w设为一个很小的定值,但在本例中,我们选择使用邻域像素之间的近似导数值,这可以通过CG的fwidth函数来得到。

实现

  1. 首先我们需要声明本例使用各个属性:

    _Ramp是用于控制漫反射色调的渐变纹理

    _Outline用于控制轮廓线宽度

    _OutlineColor对应了轮廓线颜色

    _Specular是高光反射颜色

    _SpecularScale用于控制计算高光反射时使用的阈值。

  2. 定义渲染轮廓线需要的Pass,并使用Cull Front指令把正面的三角面片剔除,而只渲染背面。

  3. 定义描边需要的顶点着色器和片元着色器,如前面所讲,在顶点着色器中我们首先把顶点和法线变换到视角空间下,这是为了让描边可以在观察空间达到最好的效果。

  4. 随后,我们设置法线的z分量,对其归一化后在将顶点沿其法线方向扩张,得到扩张后的顶点坐标。对法线的处理是为了尽可能避免背面扩张后的顶点挡住正面的面片。最后,我们把顶点从视角空间变换到裁剪空间。

  5. 在片元着色器的代码中,我们只需用轮廓线颜色渲染整个背面即可。

  6. 定义光照模型所在的Pass,将LightMode设置为ForwardBase,使用cull剔除背面,并且使用**#pragma**语句设置了编译指令,这些都是为了让Shader中的光照变量可以被正确赋值。

  7. 在片元着色器中包含了就算光照模型的关键代码。

    • 首先,我们计算了光照模型中需要的各个方向矢量,并对它们进行了归一化处理。
    • 然后我们计算了材质的反射率albedo和环境光照ambient。
    • 接着我们使用内置的UNITY_LIGHTMODEL_AMBIENT宏来计算当前世界坐标下的阴影值。
    • 随后我们计算了半兰伯特漫反射系数,并和阴影值相乘得到最终的漫反射系数。我们使用这个漫反射系数对渐变纹理_Ramp进行采样,并将结果和材质的反射率、光照颜色相乘,作为最后的漫反射光照。
    • 我们使用fwidth对高光区域的边界进行抗锯齿处理,并将计算而得的高光反射系数和高光反射颜色相乘,得到高光反射部分。值得注意的是,我们在最后还使用了step(0.0001, _SpecularScale),这是为了在_SpecularScale为0时,可以完全消除高光反射光照。
    • 最后返回环境光照、漫反射光照和高光反射光照叠加的结果。

具体代码实现见下文

素描风格的渲染

另一种非常流行的非真实感渲染是素描风格的渲染。微软研究院的Praun等人在2001年的SIGGRAPH上发表了一篇非常著名的论文。在这篇文章中,它们使用了提前生成的素描文理来实现实时的素描风格渲染,这些纹理组成了一个色调艺术映射(Tonal Art Map,TAM),如下图所示:

从左到右纹理中的笔触逐渐增多,用于模拟不同光照下的漫反射效果,从上到下则对应了每张纹理的多级渐远纹理(mipmaps)。

原理

本节将会实现简化版的论文中提出的算法,我们不考虑多级渐远纹理的生成,而直接使用6张素描纹理进行渲染。

  1. 在渲染阶段,我们首先在顶点着色阶段计算逐顶点的光照,
  2. 根据光照结果来决定6张纹理的混合权重,并传递给片元着色器。
  3. 然后,在片元着色器中根据这些权重来混合6张纹理的采样结果。

在学习完本节后,我们会得到类似下图的效果。

实现

  1. 首先,声明渲染所需的各个属性;其中_Color是用于控制模型颜色的属性。_TileFactor是纹理的平铺系数,_TileFactor越大,模型上的素描线条越密,在实现上图的过程中,我们把_TileFactor设置为8,_Hatch0至_Hatch5对应了渲染时使用的6张素描纹理,它们的线条密度依次增大。
  2. 由于素描风格往往也需要在物体周围渲染轮廓线,因此我们直接使用前面使用的渲染轮廓的Pass,我们使用UsePass命令调用了前面实现轮廓线渲染的Pass。
  3. 由于我们需要在顶点着色器中计算6张纹理的混合权重,我们首先需要在v2f结构体中添加相应的变量,由于一共声明了6张纹理,这意味着需要6个混合权重,我们把它们存储在2个fixed3类型的变量( hatchWeights0和 hatchWeights1)中。
  4. 然后,我们定义了关键的顶点着色器:
    • 我们首先对顶点进行了基本的坐标变换。
    • 然后,使用_TileFactor得到了纹理采样坐标。
    • 在计算6张纹理的混合权重之前。我们首先需要计算逐顶点光照。因此,我们使用世界空间下的光照方向和法线方向得到漫反射系数diff。
    • 之后,我们把权重值初始化为0,并把diff缩放到[0,7]范围,得到hatchFactor。我们把[0,7]的区间均匀划分为7个子区间,通过判断hatchFactor所处的子区间来计算对应的纹理混合权重。
    • 最后,我们计算了顶点的世界坐标。
  5. 接下来定义片元着色器部分:
    • 当得到了6张纹理的混合权重后,我们对每张纹理进行采样并和它们对应的权重值相乘得到每张纹理的采样颜色。
    • 我们还计算了纯白在渲染中的贡献度,这是通过从1中减去所有6张纹理的权重来得到的。这是因为素描中往往有留白的部分,因此我们希望在最后的渲染中光照最亮的部分是纯白色的。
    • 最后,我们混合了各个颜色值,并和阴影atten、模型颜色_Color相乘后返回最终的渲染结果。

具体代码实现见下文

代码实现

卡通风格的渲染

Shader "Unity Shaders Book/Chapter 14/Toon Shading"
{
    Properties
    {
        _Color ("Color Tint", Color) = (1, 1, 1, 1)
        _MainTex ("Main Tex", 2D) = "white" { }
        // 用于控制漫反射色调的渐变纹理
        _Ramp ("Ramp Texture", 2D) = "white" { }
        // 轮廓线宽度
        _Outline ("Outline", Range(0, 1)) = 0.1
        // 轮廓线颜色
        _OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
        // 高光反射颜色
        _Specular ("Specular", Color) = (1, 1, 1, 1)
        // 控制高光反射的阈值
        _SpecularScale ("Specular Scale", Range(0, 0.1)) = 0.01
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" "Queue" = "Geometry" }
        
        Pass
        {
            
            NAME "OUTLINE"
            // 剔除正面,因为该Pass只渲染背面
            Cull Front
            
            CGPROGRAM
            
            #pragma vertex vert
            #pragma fragment frag
            
            #include "UnityCG.cginc"
            
            float _Outline;
            fixed4 _OutlineColor;
            
            struct a2v
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL;
            };
            
            
            struct v2f
            {
                float4 pos: SV_POSITION;
            };
            
            v2f vert(a2v v)
            {
                v2f o;
                // 将顶点变换到视角空间下
                float4 pos = float4(UnityObjectToViewPos(v.vertex), 1.0);
                // 将法线变换到视角空间下
                float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
                // 设置法线分量
                normal.z = -0.5;
                // 对法线归一化后,将顶点沿法线方向扩张
                pos = pos + float4(normalize(normal), 0) * _Outline;
                // 将顶点转换到裁剪空间
                o.pos = mul(UNITY_MATRIX_P, pos);
                
                return o;
            }
            
            float4 frag(v2f i): SV_Target
            {
                // 使用轮廓线颜色渲染背面
                return float4(_OutlineColor.rgb, 1);
            }
            
            ENDCG
            
        }
        
        Pass
        {
            // 渲染路径 = 前向渲染Base
            Tags { "LightMode" = "ForwardBase" }
            
            // 剔除背面
            Cull Back
            
            CGPROGRAM
            
            #pragma vertex vert
            #pragma fragment frag
            
            // 前向渲染预编译指令
            #pragma multi_compile_fwdbase
            
            
            #include "UnityCG.cginc"
            // 提供光照参数的文件
            #include "Lighting.cginc"
            // 提供计算光照衰减宏的文件
            #include "AutoLight.cginc"
            #include "UnityShaderVariables.cginc"
            
            fixed4 _Color;
            sampler2D _MainTex;
            float4 _MainTex_ST;
            sampler2D _Ramp;
            fixed4 _Specular;
            fixed _SpecularScale;
            
            struct a2v
            {
                float4 vertex: POSITION;
                float3 normal: NORMAL;
                float4 texcoord: TEXCOORD0;
                float4 tangent: TANGENT;
            };
            
            
            struct v2f
            {
                float4 pos: POSITION;
                float2 uv: TEXCOORD0;
                float3 worldNormal: TEXCOORD1;
                float3 worldPos: TEXCOORD2;
                
                
                // 使用宏声明一个名为_ShadowCoord的阴影纹理坐标变量
                // 由于前面已经占用了2个寄存器,所以参数是3,代表使用第四个寄存器
                SHADOW_COORDS(3)
            };
            
            v2f vert(a2v v)
            {
                v2f o;
                
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
                // 世界空间下法线位置
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                // 世界空间下顶点位置
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                
                // 使用宏计算阴影纹理变量
                TRANSFER_SHADOW(o);
                
                return o;
            }
            
            float4 frag(v2f i): SV_Target
            {
                // 世界空间下的法线
                fixed3 worldNormal = normalize(i.worldNormal);
                // 世界空间下光照方向和视角方向、半角方向。
                fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
                fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
                fixed3 worldHalfDir = normalize(worldLightDir + worldViewDir);
                
                // 反射贴图 = 纹理采样 * 叠加颜色
                fixed4 c = tex2D(_MainTex, i.uv);
                fixed3 albedo = c.rgb * _Color.rgb;
                
                // 环境光 = 环境光宏 * 反射贴图
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
                
                // 使用宏对阴影纹理进行采样,输出到变量atten中
                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
                
                // 半兰伯特反射系数
                fixed diff = dot(worldNormal, worldLightDir);
                diff = (diff * 0.5 + 0.5) * atten;
                
                // 漫反射 = 入射光线 * 反射率 * (采样渐变纹理和半兰伯特系数)
                fixed3 diffuse = _LightColor0.rgb * albedo * tex2D(_Ramp, float2(diff, diff)).rgb;
                
                // 高光反射
                fixed spec = dot(worldNormal, worldHalfDir);
                // fwidth:计算两个相邻像素的高光差
                fixed w = fwidth(spec) * 2.0;
                // 高光反射 = 高光反射颜色 * 插值(0,1,(插值(0,1,(spec + _SpecularScale - 1))* step(0/1))
                // smoothstep:参数小于-w返回0,大于w返回1,否则插值0-1
                // step:参数2大于参数1返回1,否则返回0
                fixed3 specular = _Specular.rgb * lerp(0, 1, smoothstep(-w, w, spec + _SpecularScale - 1)) * step(0.0001, _SpecularScale);
                // 最终结果 = 环境光照 + 漫反射光照 + 高光反射光照
                return fixed4(ambient + diffuse + specular, 1.0);
            }
            
            ENDCG
            
        }
    }
    FallBack "Diffuse"
}

素描风格的渲染

///
///  Reference: 	Praun E, Hoppe H, Webb M, et al. Real-time hatching[C]
///						Proceedings of the 28th annual conference on Computer graphics and interactive techniques. ACM, 2001: 581.
///
Shader "Unity Shaders Book/Chapter 14/Hatching"
{
    Properties
    {
		// 控制模型颜色属性
        _Color ("Color Tint", Color) = (1, 1, 1, 1)
		// 纹理平铺系数,值越大,素描线条越密
        _TileFactor ("Tile Factor", Float) = 1
		// 轮廓线
        _Outline ("Outline", Range(0, 1)) = 0.1
		// 素描纹理
        _Hatch0 ("Hatch 0", 2D) = "white" { }
        _Hatch1 ("Hatch 1", 2D) = "white" { }
        _Hatch2 ("Hatch 2", 2D) = "white" { }
        _Hatch3 ("Hatch 3", 2D) = "white" { }
        _Hatch4 ("Hatch 4", 2D) = "white" { }
        _Hatch5 ("Hatch 5", 2D) = "white" { }
    }
    
    SubShader
    {
		// 		渲染类型 = 不透明物体		渲染队列 = 默认队列
        Tags { "RenderType" = "Opaque" "Queue" = "Geometry" }
        
		// 调用卡通渲染中的轮廓线Pass渲染轮廓
        UsePass "Unity Shaders Book/Chapter 14/Toon Shading/OUTLINE"
        
        Pass
        {
			// 渲染通道 = 前向渲染Base
            Tags { "LightMode" = "ForwardBase" }
            
            CGPROGRAM
            
            #pragma vertex vert
            #pragma fragment frag
            
			// 前向渲染预编译指令
            #pragma multi_compile_fwdbase
            
            #include "UnityCG.cginc"
            #include "Lighting.cginc"
            #include "AutoLight.cginc"
            #include "UnityShaderVariables.cginc"
            
            fixed4 _Color;
            float _TileFactor;
            sampler2D _Hatch0;
            sampler2D _Hatch1;
            sampler2D _Hatch2;
            sampler2D _Hatch3;
            sampler2D _Hatch4;
            sampler2D _Hatch5;
            
            struct a2v
            {
                float4 vertex: POSITION;
                float4 tangent: TANGENT;
                float3 normal: NORMAL;
                float2 texcoord: TEXCOORD0;
            };
            
            struct v2f
            {
                float4 pos: SV_POSITION;
                float2 uv: TEXCOORD0;
				// 用于存储6个阴影混合权重值
                fixed3 hatchWeights0: TEXCOORD1;
                fixed3 hatchWeights1: TEXCOORD2;

                float3 worldPos: TEXCOORD3;
				// 阴影纹理
                SHADOW_COORDS(4)
            };
            
            v2f vert(a2v v)
            {
                v2f o;
                
                o.pos = UnityObjectToClipPos(v.vertex);
                
				// 获取纹理采样坐标
                o.uv = v.texcoord.xy * _TileFactor;
                
				// 世界空间下光源方向
                fixed3 worldLightDir = normalize(WorldSpaceLightDir(v.vertex));
				// 世界空间下法线
                fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
				// 顶点漫反射系数
                fixed diff = max(0, dot(worldLightDir, worldNormal));
                
				// 初始化阴影混合权重
                o.hatchWeights0 = fixed3(0, 0, 0);
                o.hatchWeights1 = fixed3(0, 0, 0);
                
				// 将漫反射系数划分为7等分
                float hatchFactor = diff * 7.0;
                
                if (hatchFactor > 6.0)
                {
                    // 纯白色,什么都不做
                }
                else if (hatchFactor > 5.0)
                {
                    o.hatchWeights0.x = hatchFactor - 5.0;
                }
                else if(hatchFactor > 4.0)
                {
                    o.hatchWeights0.x = hatchFactor - 4.0;
                    o.hatchWeights0.y = 1.0 - o.hatchWeights0.x;
                }
                else if(hatchFactor > 3.0)
                {
                    o.hatchWeights0.y = hatchFactor - 3.0;
                    o.hatchWeights0.z = 1.0 - o.hatchWeights0.y;
                }
                else if(hatchFactor > 2.0)
                {
                    o.hatchWeights0.z = hatchFactor - 2.0;
                    o.hatchWeights1.x = 1.0 - o.hatchWeights0.z;
                }
                else if(hatchFactor > 1.0)
                {
                    o.hatchWeights1.x = hatchFactor - 1.0;
                    o.hatchWeights1.y = 1.0 - o.hatchWeights1.x;
                }
                else
                {
                    o.hatchWeights1.y = hatchFactor;
                    o.hatchWeights1.z = 1.0 - o.hatchWeights1.y;
                }
                
				// 顶点的世界坐标
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                
				// 阴影纹理采样坐标
                TRANSFER_SHADOW(o);
                
                return o;
            }
            
            fixed4 frag(v2f i): SV_Target
            {
				// 根据混合权重,对六个纹理采样后乘以混合权重,得到每张纹理的采样颜色
                fixed4 hatchTex0 = tex2D(_Hatch0, i.uv) * i.hatchWeights0.x;
                fixed4 hatchTex1 = tex2D(_Hatch1, i.uv) * i.hatchWeights0.y;
                fixed4 hatchTex2 = tex2D(_Hatch2, i.uv) * i.hatchWeights0.z;
                fixed4 hatchTex3 = tex2D(_Hatch3, i.uv) * i.hatchWeights1.x;
                fixed4 hatchTex4 = tex2D(_Hatch4, i.uv) * i.hatchWeights1.y;
                fixed4 hatchTex5 = tex2D(_Hatch5, i.uv) * i.hatchWeights1.z;
				// 计算纯白部分,使用1减去所有6张纹理的权重来得到的
                fixed4 whiteColor = fixed4(1, 1, 1, 1) * (1 - i.hatchWeights0.x - i.hatchWeights0.y - i.hatchWeights0.z -
                i.hatchWeights1.x - i.hatchWeights1.y - i.hatchWeights1.z);
                
				// 混合各个纹理与纯白,得到最终的贴图颜色
                fixed4 hatchColor = hatchTex0 + hatchTex1 + hatchTex2 + hatchTex3 + hatchTex4 + hatchTex5 + whiteColor;
                
				// 计算阴影
                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
                
				// 最终结果 = 贴图颜色 * 模型颜色 * 阴影
                return fixed4(hatchColor.rgb * _Color.rgb * atten, 1.0);
            }
            
            ENDCG
            
        }
    }
    FallBack "Diffuse"
}