序言
卡通渲染属于NPR,即非真实感渲染,在画面表现上追求比较艺术感的效果
本篇主要记录我对卡渲的理解及实现
想想看手绘的卡通漫画,就笔者学到的流程,可以了解到卡通渲染的特点在于
- 描边
- 颜色的色阶少:明暗区域的二分
描边
其他文章有基于后处理的描边,但相对面向于更复杂的场景,对单一物体来说没甚必要。
本篇的实现是:在看得到的前面绘制完成后,在后面的物体的轮廓向外偏移部分,从而实现描边
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
}

修正描边粗细问题
因为是在世界空间进行的偏移,所以在放大后,描边也更粗了

嗯,要保持无论摄像机距离远近,都要在摄像机空间下得到同样宽度的描边,就需要在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中有
而切线从M到V,设变换矩阵为M,那么
设法线的变换矩阵为A,则
我们知道,N · T = 0是一定的,无论在什么空间,所以
这样的话,就要求
最终,得到结论
[!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)
假设原来的裁剪空间坐标是:
我们希望它相对于原来的位置偏移\(\Delta x_{\mathrm{ndc}}\),所以:
两边乘以 (w):
啊,令人头疼的数学部分终于结束了,来看看效果吧

描边粗细的统一
如上图,有细心的小伙伴会观察到,小球中间的描边宽,上下细。
原因在于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就能得到宽高比了,用上面的只是为了提个问题(可恶的笔者,竟然水文!)

硬边的断边问题

在只读取法线的情况下,四条边会出现断点。
这是因为硬边根据法线偏移。
需要将相同位置顶点的多个法线进行平均运算。
将计算的结果放到切线中,因为只有法线和切线会随骨骼动画变化
以下是借鉴过来的实现代码,需要在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,相信以后会熟悉的,接下来我该复习期中考试了,还有三四天吧,一点没学呢[乐]。
浙公网安备 33010602011771号