基于URP的卡通渲染

Posted on 2026-10-27 21:26  Latencale  阅读(9)  评论(0)    收藏  举报

序言

卡通渲染属于NPR,即非真实感渲染,在画面表现上追求比较艺术感的效果
本篇主要记录我对卡渲的理解及实现

想想看手绘的卡通漫画,就笔者学到的流程,可以了解到卡通渲染的特点在于

  1. 描边
  2. 颜色的色阶少:明暗区域的二分

描边

其他文章有基于后处理的描边,但相对面向于更复杂的场景,对单一物体来说没甚必要。
本篇的实现是:在看得到的前面绘制完成后,在后面的物体的轮廓向外偏移部分,从而实现描边

Pass
{
    Name "Outline"
    Tags { "LightMode" = "SRPDefaultUnlit"
     
    Cull Front // 剔除正面,只渲染背
    
    HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment fra
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

	float _OutlineWidth;
	half4 _OutlineColor;
	half4 _MainColor;

    struct a2v
    {
        float4 vertex : POSITION;
        float3 normal : NORMAL;
        float2 uv : TEXCOORD0;
        float4 vertexColor : Color;
        float4 tangent : TANGENT;
    }
    
    struct v2f{
        float4 pos : SV_POSITION;
    }
    
    v2f vert(a2v v){
        v2f i;
		half3 expandedPos = v.vertex.xyz + v.normal * _OutlineWidth;
        i.pos = TransformObjectToHClip(expandedPos);
        return i;
    }
    
    half4 frag(v2f i) : SV_TARGET
    {
        return _OutlineColor;
    }
    
    ENDHLSL
}
    

outline.png

修正描边粗细问题

因为是在世界空间进行的偏移,所以在放大后,描边也更粗了
outline2.png

嗯,要保持无论摄像机距离远近,都要在摄像机空间下得到同样宽度的描边,就需要在NDC空间进行外扩了

v2f i;
float4 pos = TransformObjectToHClip(v.vertex);
float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal.xyz);
float3 ndcNormal = normalize(mul((float3x3)UNITY_MATRIX_P, viewNormal)) * pos.w;
pos.xy += 0.01 * _OutlineWidth * ndcNormal.xy;
i.pos = pos;
return i;

[!question] 到这里可能就有小伙伴要问了,为社么法线从模型空间(以下称M) 到 观察空间(V) 要乘 IT(即逆转置) 矩阵?

接下来内容有关线性代数,相信伙伴一定有所了解
要推演需要知道 法线N 与 切线T,若两者都为列向量,在M中有

\[N^T\cdot T=0 \]

而切线从M到V,设变换矩阵为M,那么

\[T_{1} =M\cdot T \]

设法线的变换矩阵为A,则

\[N_{1}=A\cdot N \]

我们知道,N · T = 0是一定的,无论在什么空间,所以

\[N_{1}^T\cdot T_{1}=(A\cdot{} N)^T \cdot(M\cdot T)=N^T\cdot A^T\cdot M\cdot T =0 \]

这样的话,就要求

\[A^T\cdot M=I \]

最终,得到结论

\[\boxed {A= (M^{-1})^T} \]

[!question] 好,那么好,又有小伙伴问了:明明viewNormal都需要 IT ,为什么到了裁剪空间(P) 就不对矩阵进行逆转置了???

还记得我们想要干什么吗——描边,而不是计算准确的光照,所以这里的ndcNormal并不是NDC空间的法线,而是用于让顶点沿着描边方向扩张的一个向量,这里乘P矩阵进行透视投影只是为了把V空间的三维坐标映射到P空间
这里说明一下,V空间物体的坐标(x, y, z, w)中,x、y、z用来表示三维坐标,z一般是1;P空间的x、y表示坐标,z、w为近裁剪面和远裁剪面,在OpenGL中一般z=-w。

[!question] 为什么要乘pos.w啊

有个前置的条件我们需要知道,光栅化之前要把vert传的裁剪空间经过NDC变成摄像机空间,会将POSITION的(x, y, z, w)都 / w,变成(x/w, y/w, z/w, 1)

假设原来的裁剪空间坐标是:

\[ x'_{\mathrm{ndc}}=\frac{x_{\mathrm{clip}}+\Delta x_{\mathrm{clip}}}{w} \]

我们希望它相对于原来的位置偏移\(\Delta x_{\mathrm{ndc}}\),所以:

\[ \Delta x_{\mathrm{ndc}} =\frac{\Delta x_{\mathrm{clip}}}{w} \]

两边乘以 (w):

\[ \boxed{\Delta x_{\mathrm{clip}} =\Delta x_{\mathrm{ndc}}\cdot w} \]

啊,令人头疼的数学部分终于结束了,来看看效果吧
outline3.png

描边粗细的统一

如上图,有细心的小伙伴会观察到,小球中间的描边宽,上下细。
原因在于NDC空间的x、y范围是[0, 1],而窗口分辨率是16:9,这就导致x轴的描边偏长,y轴偏短。所以需要根据窗口的宽高比进行修正

float3 ndcNormal = normalize(mul((float3x3)UNITY_MATRIX_P, viewNormal)) * pos.w;
float4 nearUpperRight = mul(unity_CameraInvProjection, float4(1, 1, UNITY_NEAR_CLIP_VALUE, _ProjectionParams.y));//近裁剪面右上角的点
float aspect = abs(nearUpperRight.y / nearUpperRight.x);//屏幕宽高比
ndcNormal.x *= aspect;
pos.xy += 0.01 * _OutlineWidth * ndcNormal.xy * v.vertexColor.a;
i.pos = pos;

注:
unity_CameraInvProjection的作用是:从裁剪空间变换到观察空间
UNITY_NEAR_CLIP_VALUE代表近裁剪面的深度值
_ProjectionParams.y是摄像机的近裁剪面距离

[!question] 看到这,我有点关于Unity的疑问:unity_CameraProjection和UNITY_MATRIX_P有什么区别?

unity_CameraProjection:相机的投影矩阵。
UNITY_MATRIX_P:用于当前渲染过程的 GPU 投影矩阵,可能经过图形 API 或渲染目标相关的调整。
两者都与相机投影有关
嗯,以上是AI说的,我还是没有明白,有没有大佬讲解一下呢~小弟膜拜膜拜你

其实只要用_ScreenParams.y / _ScreenParams.x就能得到宽高比了,用上面的只是为了提个问题(可恶的笔者,竟然水文!)

outline4.png

硬边的断边问题

outline5.png
在只读取法线的情况下,四条边会出现断点。
这是因为硬边根据法线偏移。
需要将相同位置顶点的多个法线进行平均运算。
将计算的结果放到切线中,因为只有法线和切线会随骨骼动画变化

以下是借鉴过来的实现代码,需要在Empty C# Script操作,然后选中物体,并在上方的Tool中使用,这样就能解决断边问题

public class PlugTangentTools
{
    [MenuItem("Tools/模型平均法线写入切线数据")]
    public static void WirteAverageNormalToTangentToos()
    {
        MeshFilter[] meshFilters = Selection.activeGameObject.GetComponentsInChildren<MeshFilter>();
        foreach (var meshFilter in meshFilters)
        {
            Mesh mesh = meshFilter.sharedMesh;
            WirteAverageNormalToTangent(mesh);
        }

        SkinnedMeshRenderer[] skinMeshRenders = Selection.activeGameObject.GetComponentsInChildren<SkinnedMeshRenderer>();
        foreach (var skinMeshRender in skinMeshRenders)
        {
            Mesh mesh = skinMeshRender.sharedMesh;
            WirteAverageNormalToTangent(mesh);
        }
    }

    private static void WirteAverageNormalToTangent(Mesh mesh)
    {
        var averageNormalHash = new Dictionary<Vector3, Vector3>();
        for (var j = 0; j < mesh.vertexCount; j++)
        {
            if (!averageNormalHash.ContainsKey(mesh.vertices[j]))
            {
                averageNormalHash.Add(mesh.vertices[j], mesh.normals[j]);
            }
            else
            {
                averageNormalHash[mesh.vertices[j]] =
                    (averageNormalHash[mesh.vertices[j]] + mesh.normals[j]).normalized;
            }
        }

        var averageNormals = new Vector3[mesh.vertexCount];
        for (var j = 0; j < mesh.vertexCount; j++)
        {
            averageNormals[j] = averageNormalHash[mesh.vertices[j]];
        }

        var tangents = new Vector4[mesh.vertexCount];
        for (var j = 0; j < mesh.vertexCount; j++)
        {
            tangents[j] = new Vector4(averageNormals[j].x, averageNormals[j].y, averageNormals[j].z, 0);
        }
        mesh.tangents = tangents;
    }
}

双色阶的实现

笔者回想着学校教的浅薄的板绘教学:线稿 -> 灰度 -> 上色
描边就相当于上线稿了,接下来就该考虑灰度和上色了。
本质上双色阶的实现就是灰度的实现,哪些地方被光照,是亮的,哪些地方没有光,就是暗的

注意哦,这是另一个通道,是要绘制物体的。

Tags 
{ 
    "RenderType"="Opaque" 
    "RenderPipeline" = "UniversalPipeline" //  URP Shader
}
LOD 100

// Pass 1:正常渲染模型本体 
Pass
{
    Name "ForwardLit"
    Tags { "LightMode" = "UniversalForward" }

    Cull Back

    HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment frag

    // 引入 URP 核心库
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

    //URP中,纹理与采样器拆开了
    TEXTURE2D(_MainTex);  //声明纹理
    SAMPLER(sampler_MainTex); //声明采样器

    float4 _MainTex_ST;
    half3 _MainColor;

    half4 _SpecColor;
    half _SpecGloss;

    half3 _ShadowColor;
    float _ShadowSmooth;
    float _ShadowRange;

    half4 _RimColor;
    half _RimMin,_RimMax;
    float _RimBloomExp, _RimBloomMulti;

    float _OutlineWidth;
    half4 _OutlineColor;
                

    struct a2v
    {
        float4 pos : POSITION;
        float3 normal : NORMAL;
        float2 uv : TEXCOORD0;
    };

    struct v2f
    {
        float4 pos : SV_POSITION;
        float2 uv : TEXCOORD0;
        float3 worldNormal : TEXCOORD1;
        float3 worldPos : TEXCOORD2;
    };

    v2f vert(a2v v)
    {
        v2f o;
        o.pos = TransformObjectToHClip(v.pos);
        o.uv = TRANSFORM_TEX(v.uv,_MainTex);
        o.worldNormal = TransformObjectToWorldNormal(v.normal);
        o.worldPos = TransformObjectToWorld(v.pos.xyz);
        return o;
    }
    half4 frag(v2f i) : SV_Target
	{
		……
    }
    ENDHLSL
}

漫反射

half4 col = 1;
half4 mainTex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos);
half3 worldNormal = normalize(i.worldNormal);
half3 worldLightDir = normalize(_MainLightPosition.xyz);

//漫反射
half halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
half ramp = smoothstep(0, _ShadowSmooth, halfLambert - _ShadowRange); //渐变系数
half3 diffuse = lerp(_ShadowColor, _MainColor, ramp);
diffuse *= mainTex;

col.rgb = _MainLightColor * diffuse;
return col;

想必看过GAMES101的同学们都对Blinn_Phong不陌生(没有看过的同学们推荐去看),那viewDir、worldLightDir什么的应该很熟悉,写出来也很简单。

唯一值得说的是Lambert漫反射模型,但也就是在世界空间法线和世界光方向向量做点积。
得到的结果可以smoothstep进行渐变一下,让阴影变软一点,仅此而已。
自己调一下每个属性就知道都是什么了,相信我,比上面描边部分简单多了,你一定会的。

这个ramp也可以采样贴图,进行一种渐变。话说如果贴图不是渐变的,而是随机挂着不同的颜色,想必模型的漫反射部分会很精彩吧。

镜面反射(高光)

//镜面反射
half halfDir = normalize(viewDir + worldLightDir);
half NdotH = saturate(dot(worldNormal, halfDir));
half specIntensity = pow(NdotH, _SpecGloss);
half NdotL = max(0, dot(worldNormal, worldLightDir));
half specMask = step(0.001, NdotL);
half3 specular = _SpecColor.rgb * specIntensity * specMask;

就是Blinn_Phong,然后再加个遮罩而已。
遮罩换成贴图就能进行人工操作,控制高光范围。

边缘光

//边缘光
half fresnel = 1.0 - saturate(dot(viewDir, worldNormal));
half rimRange = smoothstep(_RimMin, _RimMax, fresnel);
half rimBloom = pow(fresnel, _RimBloomExp) * _RimBloomMulti * NdotL; 
                
col.a = rimBloom; //Bloom
col.rgb = _MainLightColor * (diffuse + specular) + _RimColor.rgb * _RimColor.a * rimRange;
return col;

对于比较软的表面,摄像机看到的物体边缘的法线与像素到摄像机的向量进行点乘后,已经很接近垂直了,所以边缘光会更亮;对于较内的像素的点乘结果就比较大,导致边缘光较小。

总结

这些只是卡渲很简单的一些,感觉更适合放物体上,又或者整个场景都是这种,看上去会比较画风统一。目前这些,对于游戏人物或者更细节的场景那种带贴图的模型来说,看起来效果并不好。
最大的用处可能是让笔者认识到URP相较于BRP需要更改的东西真的好多,还有起了个开头的作用,万事开头难嘛。
这些东西花了我将近四天的时间,因为是第一次搜集资料,问AI,记笔记的shader,相信以后会熟悉的,接下来我该复习期中考试了,还有三四天吧,一点没学呢[乐]。

参考

[https://zhuanlan.zhihu.com/p/109101851]