七彩极 发表于 2022-11-16 09:36

《Unity Shader入门精要》笔记(二十七)

本文为《Unity Shader入门精要》第十四章《非真实感渲染》的第一节内容《卡通风格的渲染》。
本文相关代码,详见:


原书代码,详见原作者github:

<hr/>非真实感渲染(Non-Photorealistic Rendering,NPR)的主要目标:使用一些渲染方法,使得画面达到和某些特殊的绘画风格相似的效果,例如:卡通、水彩风格等。
本章将学习两种常见的非真实感渲染方法:卡通风格的渲染、实时素描效果的渲染。本次笔记将学习卡通风格的渲染。
1. 概念原理

卡通风格的渲染共有的特点:黑色的线性描边、分明的明暗变化等。

实现卡通渲染的方法有很多,其中之一是:使用基于色调的着色技术(tone-based shading)。

[*]漫反射部分:使用漫反射系数对一张一维纹理进行采样,以控制漫反射的色调;
[*]高光部分:是一块块分界明显的纯色区域(与以往渐变过渡的高光不同);
[*]额外:在物体边缘绘制轮廓(基于模型的描边方法)。

1.1 渲染轮廓线

《Real Time Rendering》第三版中,作者把这些方法分成5类:

[*]基于观察角度和表面法线的轮廓线渲染
使用视角方向和表面法线的点乘结果得到轮廓线的信息。
优点:简单快速,一个Pass即可得到渲染结果;
缺点:描边效果不尽如人意。


[*]过程式几何轮廓线渲染
2个Pass渲染,第一个Pass渲染背面的面片,通过默写技术让轮廓可见,第二个Pass正常渲染背面的面片。
优点:快速有效,适用于表面平滑的模型;
缺点:不适合平整的模型。


[*]基于图像处理的轮廓线渲染
基于图像后处理,在所有物体渲染后,通过边缘检测判定轮廓。
优点:适用于任何种类的模型;
缺点:深度和法线变换很小的轮廓无法被检测出来,例如:桌面上的纸张。


[*]基于轮廓边检测的轮廓线渲染
通过判定两个相邻三角面片,是否一个朝正面,一个朝反面来判定是否为轮廓边。
优点:1. 前几种渲染最大的问题——无法控制轮廓线的风格渲染,但是这种方法可以;2. 精准;
缺点:1. 实现相对复杂;2. 由于是逐帧单独提取轮廓,帧与帧之间会出现跳跃性。


[*]综合上述方法
例如:先找到精确的轮廓边,把模型和轮廓边渲染到纹理中,再使用图像处理的方法识别出轮廓线,并在图像空间下进行风格化渲染。

本节将会在Unity中使用过程式几何轮廓渲染的方法进行轮廓描边,在第一个Pass中使用轮廓线颜色渲染整个背面的面片,并在视角空间下把模型顶点沿着法线方向向外扩展一段距离,以此让背面轮廓线可见:
viewPos = viewPos + viewNormal * _Outline;

但对于内凹的模型,会发生背面面片遮挡正面面片的情况。降低这种情况发生的可能,在扩张背面顶点之前,首先对顶点法线的z分量进行处理,使其等于一个定值,然后把法线归一化后再对顶点进行扩张,这样扩张后的背面更加扁平化:
viewNormal.z = -0.5;
viewNomal = normalize(viewNormal);
viewPos = viewPos + viewNormal * _Outline;

1.2 添加高光

卡通风格中的高光是模型上一块块分界明显的纯色区域。因此不能在使用之前学习的光照模型,回顾一下之前实现的Blinn-Phong模型:
// 使用法线点乘光照方向以及视角方向和的一半,再和另一个参数进行指数运算得到高光发射
float specular = pow(max(0, dot(normal, halfDir)), _Gloss);

为了得到分界明显的效果,我们同样需要计算normal和halfDir的点乘结果,但不同的是通过这个结果和阈值进行比较,是最终返回结果是0或1:
float specular = dot(worldNormal, worldHalfDir);
// step函数:CG内置函数
// 如果spec比threshold大,则返回1;否则返回0
spec = step(threshold, spec);

但这种粗暴的判断方法会在高光区域的边界产生锯齿,因为高光边缘不是平滑渐变的,而是0到1突变的,因此需要在边界处很小的一块区域进行平滑处理:
float spec = dot(worldNormal, worldHalfDir);
// smoothstep函数:CG内置函数
// w是很小的值,当spec - threshold小于-w时,返回0;当大于w时,返回1;否则在0~1之间差值
// 这样可以让高光区域在阈值上下很小的范围实现渐变
spec = lerp(0, 1, smoothstep(-w, w, spec - threshold);

w会被设为很小的值,本次案例中w被设置为邻域像素之间的近似导数值,通过CG的fwidth函数来得到。
fwidth(c) ==> abs(ddx(c)) + abs(ddy(c))

当代GPU在像素化的时候一般是以2x2像素为基本单位,那么在这个2x2像素块当中,右侧的像素对应的fragment的x坐标减去左侧的像素对应的fragment的x坐标就是ddx;下侧像素对应的fragment的坐标y减去上侧像素对应的fragment的坐标y就是ddy,ddx和ddy代表了相邻两个像素在设备坐标系当中的距离。
摘自知乎:https://www.zhihu.com/question/329521044/answer/717906456从上面的引用可看出,fwidth就是临近像素级大小的宽度,本案例使用这个方法,就是为了让w被设置为一个足够小的值。

卡通渲染中高光往往有更多个性化的需要,更多非真实渲染的效果可前往原书作者的CSDN博客:https://blog.csdn.net/candycat1992/article/details/47284289。

2. 案例:卡通化渲染

2.1 效果预览




2.2 准备工作

完成如下准备工作:

[*]新建名为Scene_14_1的场景,并去掉天空盒;
[*]往场景中拖入一个Suzanne模型,可从文章开头的工程中找;
[*]新建名为ToonShadingMat的材质,并赋给上一步的模型;
[*]新建名为Chapter14-ToonShading的Unity Shader,并赋给上一步的材质;
[*]保存场景。

2.3 编写Shader:Chapter14-ToonShading

编写代码如下:
Shader "Unity Shaders Book/Chapter 14/Toon Shading"
{
    Properties
    {
      _Color ("Color Tint", Color) = (1.0, 1.0, 1.0, 1.0)
      _MainTex ("Main Tex", 2D) = "white" {}
      _Ramp ("Ramp Texture", 2D) = "white" {}
      _Outline ("Outline", Range(0.0, 1.0)) = 0.1
      _OutlineColor ("Outline Color", Color) = (0.0, 0.0, 0.0, 1.0)
      _Specular ("Specular", Color) = (1.0, 1.0, 1.0, 1.0)
      _SpecularScale ("Specular Scale", Range(0.0, 0.1)) = 0.01
    }

    SubShader
    {
      // 背面渲染Pass
      Pass
      {
            // 为Pass命名,方便后续复用
            NAME "OUTLINE"
            // 只渲染背面面片
            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 worldPos = mul(UNITY_MATRIX_MV, v.vertex);
                float3 worldNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
                // 将z分量设置为某个固定值,降低内凹模型的背面面片挡住正面面片的可能性
                worldNormal.z = -0.5;

                // 顶点位置沿法线方向偏移一定距离
                worldPos = worldPos + float4(normalize(worldNormal), 0) * _Outline;
                o.pos = mul(UNITY_MATRIX_P, worldPos);


                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return fixed4(_OutlineColor.rgb, 1);
            }
            ENDCG
      }

      // 正面渲染Pass
      Pass
      {
            Tags
            {
                "LightMode" = "ForwardBase"
            }

            // 只渲染正面
            Cull Back

            CGPROGRAM

            #include "UnityCG.cginc"
            #include "AutoLight.cginc"
            #include "Lighting.cginc"

            #pragma vertex vert
            #pragma fragment frag

            fixed4 _Color;
            sampler2D _MainTex;
            float4 _MainTex_ST;
            // 控制漫反射色调的渐变纹理
            sampler2D _Ramp;
            // 高光颜色
            fixed4 _Specular;
            // 控制高光反射时使用的阈值
            float _SpecularScale;

            struct a2v
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 pos : POSITION;
                float2 uv : TEXCOORD0;
                float3 worldNormal : TEXCOORD1;
                float3 worldPos : TEXCOORD2;
                SHADOW_COORDS(3)
            };

            v2f vert(a2v v)
            {
                v2f o;

                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
                // 世界空间的法线方向
                o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
                // 世界空间的顶点位置
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

                TRANSFER_SHADOW(o);

                return o;
            }

            fixed4 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;
               
                // 光照衰减
                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函数,让w被设置为一个很小的值
                fixed w = fwidth(spec) * 2.0;
                // 阈值控制高光边缘渐变
                float stepFactor = smoothstep(-w, w, spec + _SpecularScale - 1);
                // 最后使用step函数,为了在_SpecularScale为0时,可以完全消除高光反射的光照
                fixed3 specular = _Specular.rgb * lerp(0, 1, stepFactor) * step(0.0001, _SpecularScale);

                return fixed4(ambient + diffuse + specular, 1.0);
            }

            ENDCG
      }
    }

    Fallback "Diffuse"
}

2.4 配置材质

配置好如下材质,即可得到最终的效果:



以上是本次笔记的所有内容,下一篇笔记我们将学习《素描风格的渲染》的相关知识。

<hr/>
写在最后

本文内容会同步发在笔者的公众号上,欢迎大家关注交流!
公众号:程序员叨叨叨(ID:i_coder)
页: [1]
查看完整版本: 《Unity Shader入门精要》笔记(二十七)