【【02】卡通渲染基本光照模型的实现
本文首发于知乎专栏,转载请注明出处 。
前言
本文作为本专栏
的第一篇技术实现帖,本文将在unity实现一个基本的卡通渲染的光照模型。本文暂时不使用任何纹理,只是先把光照模型实现一下,也暂时不对金属材质做特殊处理。之后的文章中,只要将光照的系数乘上纹理的采样结果即可。特别的,一些用于遮蔽的纹理本文也暂时不使用。
1. 轮廓线的实现
首先,我们按照《GGXrd SIGN》的outline的思路先实现一个基本的轮廓线。
基本思路是,对一个物体做两边遍渲染,用第二遍渲染来实现轮廓线。在第二遍渲染的时候开启正面剔除,在顶点着色器中把顶点沿法线向外延伸一段距离(放大物体)。那么挡在物体前面的部分不会显示,被物体挡住的由于深度剔除也不会显示,就只有轮廓的部分会被保留下来。但这里要注意的是,画轮廓线应该放在第二个Pass进行。如果把两个Pass的顺序调换,当然渲染的结果是一样的,但由于物体部分的像素会被绘制两遍而降低性能。如果把轮廓线放在第二个Pass,可以利用深度测试节省这部分性能。
那么在Unity里放一个球来试一下。我一般喜欢用球来做最开始的着色器调试,因为球的表面均匀分布了所有方向的法线。这里先创建一个unity的Standard的PBR shader给球的材质。然后在subshader的最后加上画轮廓线的Pass。
Pass{
Name "OUTLINE"
Tags{
"LightMode" = "Always" }
Cull Front
ZWrite On
ColorMask RGB
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texCoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float4 color : COLOR;
float4 tex : TEXCOORD0;
};
half _OutlineWidth;
fixed4 _OutlineColor;
v2f vert(appdata v) {
// just make a copy of incoming vertex data but scaled according to normal direction
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
float3 norm = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
float2 extendDir = normalize(TransformViewToProjection(norm.xy));
o.pos.xy += extendDir * (_OutlineWidth * 0.1);
o.tex = v.texCoord;
o.color = _OutlineColor;
return o;
}
half4 frag(v2f i) :COLOR {
return i.color;
}
ENDCG
}//Pass
顶点着色器中将投影到投影空间的顶点坐标向投影空间的法线方向做一段延伸。注意将法线变换到投影空间要乘以transform的转置逆再乘projection矩阵,这样法线不会受到非等比缩放的影响。然后我们看看效果。
基本实现了一个描边的效果,但是在不同深度下仔细观察还是有一些问题。
可以看到,当把摄像头拉进,轮廓线会很粗,摄像头拉远,轮廓线会很细。但是在本专栏的【01】开篇里我们看到,重力眩晕2里的轮廓线是不会随深度而改变的。也就是现在的效果并不是我想要的。我这里解释一下原因及解决办法。
讲原因之前,我们首先来看投影空间:投影空间详解
一个顶点坐标的四个值(x,y,z,w),x,y表示投影空间下的横纵坐标,z表示投影空间下的深度值,w等于z,w用于做归一化。
o.pos = UnityObjectToClipPos(v.vertex);
也就是说,顶点着色器的这行坐标变换得到的x,y的范围是(-w, w)。当在屏幕上做栅格化的时候,管线会将x,y的坐标值除以w就能得到(-1, 1)范围的坐标(ndc)。我们希望得到的是显示在屏幕上的固定宽度的轮廓线,那么顶点向外延伸的距离应该是ndc空间下的固定距离,而不是投影空间下的固定距离。于是我在投影空间下做计算的时候只要将轮廓线宽度乘上w值,再后续的计算中,管线会将坐标值除以w,得到的仍然是人为设定的轮廓线宽度。
将该行代码替换:
o.pos.xy += extendDir * (o.pos.w * _OutlineWidth * 0.1);
然后我们看下效果。
ok,轮廓线的宽度不会随深度改变了,这正是我想要的效果。上两张图轮廓线宽度_OutlineWidth 设的是0.05。
但这样还不够,本专栏【01】开篇中提到,重力眩晕中的轮廓线不是固定宽度的,它通过轮廓线宽度的变化来表现手绘的线条效果。
《重力眩晕2》中这个效果的实现可能利用了纹理来写入线条的宽度或者一些遮蔽信息,但是我暂时没去给自己down的模型画额外的纹理。那我暂时用一个简单方法来实现线条的粗细变化。我这里引入柏林噪声(Perlin Noise),柏林噪声的特点是不会剧烈变化。下面这个柏林噪声的发生函数是我从别的帖子上拿过来的,我找不到出处了,这里暂时就不注明了。
float2 hash22(float2 p) {
p = float2(dot(p, float2(127.1, 311.7)), dot(p, float2(269.5, 183.3)));
return -1.0 + 2.0 * frac(sin(p) * 43758.5453123);
}
float2 hash21(float2 p) {
float h = dot(p, float2(127.1, 311.7));
return -1.0 + 2.0 * frac(sin(h) * 43758.5453123);
}
//perlin
float perlin_noise(float2 p) {
float2 pi = floor(p);
float2 pf = p - pi;
float2 w = pf * pf * (3.0 - 2.0 * pf);
return lerp(lerp(dot(hash22(pi + float2(0.0, 0.0)), pf - float2(0.0, 0.0)),
dot(hash22(pi + float2(1.0, 0.0)), pf - float2(1.0, 0.0)), w.x),
lerp(</