文章

卡通渲染技术总结

​ ​ 最近由于项目需要,必须得接触卡通渲染相关的渲染方法。对于该领域,我之前有看过一些资料,都比较零碎且没有实践经验。只知道其中一些二值化的操作,描边等等。而其中的技术细节,艺术需求与技术实现之间的平衡思想以及背后的数学原理都比较陌生。为了解决这个问题,我决定整理一份比较系统的资料,供大家学习和参考。

​ ​ 在本文中,我会给出文字描述,也会给出代码实现,同时对背后的数学原理进行讲解。跟着我们一步步去改造Unity URP管线,实现一个Unity上的卡通渲染着色的通用管线。不过这里不会介绍UnityShader和URP的前置知识,如有需要,可以单开另一个专栏。

卡通渲染的概念

​ ​ 卡通渲染是属于非真实感计算机图形学的范畴的,分为美式卡通和日式卡通两种。

  • 美式卡通:色彩连续、存在渐变色(代表作:《军团要塞2》);
  • 日式卡通:明显的明暗交界,大范围的纯色色块(代表作:《崩坏三》);

传统渲染着色

​ ​ 在真实感图形渲染重,有多种光照模型来模拟真实光线与物体的交互方式,这类渲染方法一般称作基于物理的渲染(PBR)。而其中最真实的要属光线追踪,不过在我们这系列的文中,不会去探讨光线追踪的实现,感兴趣的可以关注我翻译的"一周光追"系列文章。
作为卡通渲染的入门级文章,我们从传统的光照模型开始介绍,并将它改造成对我们需要的形式。

Phong Shading

​ ​ Phong Shading是最早的描述光线与表面交互的光照模型,因为它没有考虑真实的物理信息,所以也称它为经验模型。

​ ​ 如下图,光线从光源照射到物体表面上,发生散射,有一部分光线进入人眼,因此我们的眼睛才能看到物体的表面。而散射的光线的能量,取决于物体表面能接收的能量。而接收的能量,又由光的入射方向与物体表面法线方向的夹角决定,当夹角越小,吸收的能量越多,散射的能量也越多,当夹角大于90°时,吸收的能量就为0,也就不会由能量散射。

​ ​ 在 Phong Shading中,提出了一个公式来描述该现象:

diffuse = n \cdot l

​ ​ 即入射光线的方向与表面法线的点积。这个公式只描述了入射能量的多少,且入射能量全都会反射出去,在入射前后能量不守恒,所以这只是一个经验模型,不符合物理规律。不过本文不会深入去介绍符合物理规律的模型,大家可以自己去找其他的资料或者看我文章中对BRDF的介绍。

环境光

​ ​ 环境光在渲染中一般叫做 Ambient Light,这也时大家习惯的命名。在一些系统中,环境光直接被定义为一个常值,这也是为了方便使用和性能而做的取舍。事实上要计算准确的环境光,需要依赖光线追踪等系列全局光照算法来做,无法实时达到要求,这也是行业的难题之一。在我们将要介绍的内容中,就直接定义一个常量即可。

表面高光

​ ​ 表面高光如下图,是指那些真正进入人眼的光线。如下图,对于一束入射光,只有一个反射方向能被眼睛看到。视线方向与光方向的中线即为反射法线,在众多公开资料中,会把它称为半程向量(h),其实它表示只有法线方向与半程向量一致的微表面可以被眼睛看见,所以在我的理解中,它其实代表的是微表面的法线。

​ ​ 在传统的 Blinn-Phong模型中,用半程向量与表面法线方向来评估最终能进入人眼的能量,作为高光项。

specular = n \cdot h

菲涅尔与边缘光(Rim)

​ ​ 菲涅尔现象是一种物理现象。在看水面时最明显,当直视水面时,可以看到水底的物体,即反射光线很少,折射光线比较多。而当视线与水面越垂直,反射光线越多。它是一个与视线方向和表面法线方向有关的量。表现在物体表面上,就是在表面与视线越垂直的部分亮度较高。
简单地评估该能量的公式如下,就是直接计算视线与法线的点乘,并用1减去该结果。

fresnel = 1 - n \cdot v

​ ​ 后续我们在计算边缘光(Rim)时会用到它。菲涅尔公式不止这一种,有更科学,更丰富的计算方法。这里只提供一个示例。

本章小结

​ ​ 在该章节中。我们介绍了传统渲染中的一些着色计算公式并简单分析了其背后的原理。最终的渲染颜色可以汇总成如下公式:

final = (ambient + diffuse + specular + fresnel)\cdot albedo

卡通渲染着色

​ ​ 在传统的渲染着色中,明暗的变化是连续的。卡通渲染着色会对上述的计算进行修改,用纯色块来表达明暗,所以所有的结算结果都要降低色阶,色阶的数量可以自己控制,我们这里的实现基本就二值化。除了环境光,其他类似漫反射,高光和边缘光都要做类似的处理。只是参数略有不同。

漫反射

​ ​ 卡通渲染漫反射分量的计算可以基于传统的 Blinn-Phong的漫反射计算进行进一步处理。即首先计算 Blinn-Phong模型的漫反射,然后再进行二值化和边缘平滑处理。

diffuse = n \cdot l

​ ​ 这里重点介绍第二步,二值化和边缘平滑。这里使用 smoothstep来处理,这个函数后面会用到很多,所以这里介绍一下它的原理。

smoothstep函数

​ ​ smoothstep函数接收两个参数,第一个参数表示下界,第二个参数表示上界,第三个参数是输入值​x,它会根据​x的值,再上界和下界之间平滑过渡。小于下界时返回0,大于上界时返回1。

image-20251019235912194.png

​ ​ 根据上面的图像,可以看到对于输入值​x,可以定义两个阈值,让小于下限的值直接变成0(形成暗部),大于上限的值变成1(形成亮部分),在上限和下限之间平滑过渡(柔化)。所以只需要一个函数,两个参数就可以实现二值化和柔化的效果。

​ ​ 更进一步,我们可以把下限和上限用一个参数控制,这样在使用的时候会方便很多。这里只展示片段着色器的代码,参数的定义和顶点着色器就省掉了。

Properties
{
    _BaseMap("Texture", 2D) = "white" {}
    _BaseColor("Color", Color) = (1,1,1,1)
    _Antialiasing("Antialiasing", Float) = 5.0
}

... 
  
float4 ToonFragment(PSInput input):SV_TARGET
{
    float3 ambient = _GlossyEnvironmentColor.rgb;
  
    Light mainLight = GetMainLight();
    float3 lightDir = normalize(mainLight.direction);

	float3 normal = normalize(input.normalWS);
  
    float ndl = dot(lightDir, normal);
    float delta = fwidth(ndl)*_Antialiasing;
    float diffuseSmooth = smoothstep(0, delta, ndl);

  
    float4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
	return float4(col.rgb*_BaseColor.rgb * ((diffuseSmooth)*mainLight.color.rgb + ambient), 1);
}

fwidth函数

​ ​ fwidth函数的公式如下:

fwidth(v) = abs(ddx(v)) + abs(ddy(v))

​ ​ 它用于计算着色器中某个值v上下像素的差值和左右像素差值的和,类似于描边里的 sobel算子之类的计算。在上面的漫反射计算中,我们使用 fwidth来计算​n\cdot l的结果,在周围像素的差值和。这样一来,在明暗交界处会有明显的变化,然后把这个结果用来作为 smoothstep函数过渡的上界,并用一个参数 _Antialiasing来控制,这样就可以得到过渡边界比较柔和的效果。

image-20251020002026803.png

高光

​ ​ 高光部分的计算,首先使用 Blinn-Phong模型的高光公式计算高光分布,然后再使用 smoothstep函数来对齐进行柔化和二值化。

float3 viewDirWS = normalize(input.viewDirWS);

float3 halfVec = normalize(lightDir + viewDirWS);
float ndh = dot(halfVec, normal);
float specular = pow(ndh*diffuseSmooth, _Glossiness);
float specularSmooth = smoothstep(0, 0.01*_Antialiasing, specular);

​ ​ 这里就不进行更多的解释,新引入的参数是 _Glossiness,可以控制高光范围。乘以​diffuseSmooth可以防止高光扩散到暗部,是直接截断的做法。

image-20251020002614865.png

边缘光

​ ​ 卡通渲染中的边缘光的计算,可以通过下面的公式简单计算它的明暗分布:

rim = 1-n\cdot v

​ ​ 对计算得到的 rim,使用 smoothstep函数对其进行柔化和二值化处理。代码如下,我们引入 _RimAmount来控制它的范围,_RimIntensity来控制它的强度,我们依然乘以​n\cdot l的结果,来防止边缘光在暗部出现。

    //  Rim
    float rim = 1 - dot(normal, viewDirWS);
    rim *= ndl;
    float rimSize = 1 - _RimAmount;
    float rimSmooth = smoothstep(rimSize, rimSize*1.1f, rim)*_RimIntensity;

最终结果

float4 ToonFragment(PSInput input):SV_TARGET
{
    float3 ambient = _GlossyEnvironmentColor.rgb;
  
    Light mainLight = GetMainLight();
    float3 lightDir = normalize(mainLight.direction);

	float3 normal = normalize(input.normalWS);
  
    float ndl = dot(lightDir, normal);
    float delta = fwidth(ndl)*_Antialiasing;
    float diffuseSmooth = smoothstep(0, delta, ndl);

	float3 viewDirWS = normalize(input.viewDirWS);

    float3 halfVec = normalize(lightDir + viewDirWS);
    float ndh = dot(halfVec, normal);
    float specular = pow(ndh*diffuseSmooth, _Glossiness);
    float specularSmooth = smoothstep(0, 0.01*_Antialiasing, specular);

    //  Rim
    float rim = 1 - dot(normal, viewDirWS);
    rim *= ndl;
    float rimSize = 1 - _RimAmount;
    float rimSmooth = smoothstep(rimSize, rimSize*1.1f, rim)*_RimIntensity;
  
    float4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
	return float4(col.rgb * _BaseColor.rgb * ((rimSmooth) * mainLight.color.rgb + ambient), 1);
}

image-20251020003535947.png

描边

​ ​ 对物体进行描边,在卡通渲染中通常使用的是法线外扩描边。不过也有一些项目中使用的是基于深度差的描边方法。这里主要讲法线外扩描边。

​ ​ 法线外扩描边,在Unity中需要另一个Pass来执行。所以我们会在Shader中定义另一个Pass。在URP框架内,需要指定一个 LightMode来对场景中所有需要描边的对象执行这个Pass。

...
Pass
{
    Tags
    {
        "Queue" = "Opaque"
        "LightMode" = "OutlineForward"
    }
  
    Cull Front
    ZTest LEqual
  
    HLSLPROGRAM

    #include "ToonOutlineInput.hlsl"
    #include "ToonOutlinePass.hlsl"
  
    #pragma vertex ToonOutlineVert
    #pragma fragment ToonOutlineFragment
    ENDHLSL
}
...

​ ​ 需要注意的是,要把物体的前面给剔除掉(Cull Front),这样只绘制背面时,深度测试会把物体前面遮挡的部分给剔除掉,沿着法线外扩的部分会保留,形成边界。

基础版描边

​ ​ 最基础的描边方式是,在顶点着色器中,将顶点沿着法线方向偏移一定的距离。

V2F VertexOutline(Attribute attr)
{
	V2F v2f;
	v2f.position = TransformObjectToHClip(attr.position + attr.normal * _OutlineWidth);

	return v2f;
}

image-20251020004210228.png

​ ​ 这种方法,当摄像机距离拉远后描边会变的越来越细,会断断续续;当摄像机拉进时,描边又会变得很粗。这两种情况非常影响视觉效果。

image-20251020004233836.png

​ ​ 这是因为法线外扩运算是基于世界空间的,外扩的距离是固定的,自然就会有近大远小的效果。

排除透视影响

​ ​ 想要去掉近大远小的效果,可以在shader中将做一个乘以w分量的操作,抵消掉透视除法,那么法线外扩的距离在屏幕空间就是固定的。

pos = TransformObjectToHClip(attr.position);
pos.xy += TransformWorldToHClipDir(TransformObjectToWorldDir(attr.normal)).xy * pos.w * _OutlineWidth;

image-20251020004340232.png

​ ​ 拉远后的效果

image-20251020004348342.png

根据相机距离,减弱描边效果

​ ​ 现在的描边是固定宽度的,当摄像机拉的很远时,物体已经很小了,但是描边仍然很粗,这是错误的效果。所以我们会对描边宽度根据距离加个衰减处理。这里采用的是 smoothstep,有经验的同学可以用表现更好的衰减方式。

half dis = distance(_WorldSpaceCameraPos, TransformObjectToWorld(attr.position));
float multiper = 1.0 - smoothstep(0, 1, dis / _OutlineMaxDistance);

pos.xy += clipNormal.xy * _OutlineWidth * pos.w * multiper;

最终实现

​ ​ 上面的资料选自 参考资料[4]。根据这些材料,我的最终实现如下。在裁剪空间中进行变换,排除透视影响,并随着相机对粗细进行变换。

struct VSInput
{
    float4 positionOS : POSITION;
    float3 normalOS : NORMAL;
    float4 tangentOS : TANGENT;
};

struct PSInput
{
    float4 positionCS:SV_POSITION;
};

PSInput ToonOutlineVert(VSInput vertex)
{
    PSInput output;

    VertexNormalInputs normalInput = GetVertexNormalInputs(vertex.normalOS, vertex.tangentOS);

    float3 positionWS = TransformObjectToWorld(vertex.positionOS);
    float distance = length(positionWS - GetCurrentViewPosition());
    float multiper = 1.0 - smoothstep(0, 1, distance / _OutlineMaxDistance);
  
    float4 positionCS = TransformObjectToHClip(vertex.positionOS);
    positionCS.xy += TransformWorldToHClipDir(normalInput.normalWS).xy * positionCS.w * _OutlineWith*multiper;
    output.positionCS = positionCS;
    return output;
}

float4 ToonOutlineFragment(PSInput input):SV_Target
{
    return float4(0, 0, 0, 1);
}

​ ​ 最终效果如下图:

image-20251020004932200.png

URP中的描边

​ ​ 在URP框架中,要使描边的Pass能够被执行,还需要创建一个 RenderFeature来控制。我们可以取名叫 OutlintFeature

image-20251020005056007.png

​ ​ 在实现中,开放两个量来控制要使用的 LightMode和物体的 Layer。这表明,只有 LightMode为指定值的Pass会被执行,且 GameObjectLayer也是指定的。

image-20251020005205594.png

​ ​ 在URP的配置面板上添加我们创建的 RenderFeature,并设置我们在Shader Pass中定义的 LightMode值,设置 LayerDefault,即只有 Default的物体会被渲染,在游戏项目中,可能是 Player或其他。

image-20251020005417146.png

​ ​ 我们还要定义 OutlineFeature要使用的 RenderPass,其实我们不必重复去造轮子,可以直接使用URP提供的 DrawObjectsPass即可。只要仿照URP的用法,传递正确的参数就可以了。

image-20251020005717081.png

参考资料

[1] 卡通渲染及其相关技术

[2] 原神角色渲染Shader分析还原

[3] 【从零到零点一的原神卡渲还原】记录还原的尝试和思考

[4] 法线外扩描边实现与优化](https://www.cnblogs.com/silence394/p/17285224.html)

许可协议:  CC BY 4.0