一、卡通渲染
卡通渲染是属于非真实感计算机图形学的范畴的,分为美式卡通和日式卡通两种。
-
美式卡通:色彩连续、存在渐变色;
-
日式卡通:明显的明暗交界,大范围的纯色色块;
二、着色
日式卡通渲染的着色,总而言之就是对Blinn-Phong模型的一个简化。这其中用到了一个关键的函数为smoothstep。
smoothstep(e0,e1,t),其中e0.e1为常量,t为变量。
2.1 传统漫反射
我们先从传统的漫反射模型说起:传统漫反射使用模型法线与光线方向的点乘值来获得光影的过渡,传统漫反射系数的过渡比较的柔和(见下图)。
常见的光照模型有Lambert模型和Half Lambert模型。
片元着色语言如下:
#version 450
layout (location = 0) in vec3 inNormal;
layout (location = 1) in vec3 inColor;
layout (location = 2) in vec3 inEyePos;
layout (location = 3) in vec3 inLightVec;
layout (location = 0) out vec4 outFragColor;
void main()
{
float ambient = 0.1;
vec4 IAmbient = vec4(0.0, 0.0, 0.0, 1.0);
vec4 IDiffuse = vec4(max(dot(inNormal, inLightVec), 0.0));
vec4 IDiffuseHalf = vec4(max(dot(inNormal, inLightVec), 0.0))*0.5+0.5;
outFragColor = vec4((IAmbient + IDiffuseHalf) * vec4(inColor, 1.0));
}
Lambert模型实现效果如下:
Half Lambert模型实现效果如下:
2.2 梯度漫反射
日式卡通渲染中的漫反射主要是对传统的漫反射系数做一个离散化的操作。
传统的漫反射是用dot(N,L)来模拟的,而卡通渲染的漫反射通过一个阈值将光照分为亮和暗两个部分:
对于日式卡通渲染,我们主要进行以下步骤:
首先将传统漫反射进行离散化,再使用smoothstep对边缘柔和度进行控制,这其中用到的关键函数是smoothstep,他是我们实现日式卡通渲染的重要函数。
#version 450
layout (location = 0) in vec3 inNormal;
layout (location = 1) in vec3 inColor;
layout (location = 2) in vec3 inEyePos;
layout (location = 3) in vec3 inLightVec;
layout (location = 0) out vec4 outFragColor;
void main()
{
float ambient = 0.1;
float _RampThreshold=0.5;
float _RampSmooth=0.2;
float diffuse= max(dot(inNormal, inLightVec),0.0);
//diffuse
//if(diffuse>_RampThreshold)
// diffuse=1;
//else
// diffuse=0;
diffuse=smoothstep(_RampThreshold-0.5*_RampSmooth,_RampThreshold+0.5*_RampSmooth,diffuse);
outFragColor = vec4(inColor, 1.0)*(ambient + diffuse) ;
}
离散实现效果如下:
边缘柔和实现效果如下:
2.3 风格化高光
卡通渲染中的风格化高光是Blinn-Phong模型的简化,依然使用半程向量和物体的表面法线来进行计算。
另外,为了达到卡通渲染简化的目的,我们依然使用了smoothstep函数来对高光系数做数值上的离散化处理。
void main()
{
...
//specular
vec3 Reflected = normalize(reflect(-inLightVec, inNormal));
vec3 Eye = normalize(-inEyePos);
float specular = pow(max(dot(Reflected, Eye), 0.0), 32.0);
vec4 ISpecular = vec4(0.0);
if (dot(inEyePos, inNormal) < 0.0)
{
specular = smoothstep(_RampThreshold-0.5*_RampSmooth,_RampThreshold+0.5*_RampSmooth,specular);
ISpecular = vec4(1, 1, 1, 1) * specular;
}
outFragColor = vec4(inColor, 1.0)*(ambient + diffuse)+ISpecular ;
}
传统高光实现效果如下:
离散化高光如下:
2.4 边缘光
2.4.1 涅菲尔
边缘光,顾名思义,指的是模型边缘内部的发光效果。
边缘光与物理中一种叫菲涅尔反射的现象相关。在现实中我们可以通过观察玻璃球来看到这种效果。
-
当我们观察玻璃球中心时,光反射的效果会比较弱
-
而当我们观察玻璃球边缘时则可以看到比较强烈的反射效果
菲涅尔现象,简而言之,就是视线垂直于表面时,反射较弱,而当视线并非垂直表面时,夹角越小,反射越明显。
边缘光的实现主要是基于视线与物体表面法线来进行的。
我们通过视线与物体表面法线的点乘来获得边缘光的强度:
- 当视线与物体表面垂直时,边缘光强度为0
- 当视线与物体表面平行时,边缘光强度为1
具体代码如下:
void main()
{
...
float rimVal = 0;
if(dot(inNormal, Eye)>=0)
rimVal = 1- max(dot(inNormal, Eye),0);
vec4 rimColor=vec4(1,1,1,1)*rimVal*NDL;
}
同时我们也需要定义一个边缘光的颜色RimColor,然后在片元着色器中将rimVal和RimColor相乘后得到边缘光的颜色分量。
通过上述计算,可以得到以下效果:
2.4.2 风格化边缘光实现
上面的效果比较写实,不符合卡通渲染通过简化进行增强这一原则。因此我们需要再加工一下,也就是离散化。
在这里我们还是使用smoothstep函数来对rimVal做一个加工,来达到简化的目的。
void main()
{
...
float rimVal = 0;
if(dot(inNormal, Eye)>=0)
rimVal = 1- max(dot(inNormal, Eye),0);
rimVal = smoothstep(_RampThreshold-0.5*_RampSmooth,_RampThreshold+0.5*_RampSmooth,rimVal);
}
最终可以得到一个这样的风格化边缘光的效果:
2.4.3 优化光照
在光照射不到的地方,我们希望不显示边缘光,因此我们可以用NDL来与rimVal相乘,这样在阴影处就不会有边缘光。
void main()
{
...
//rim
float rimVal = 0;
if(dot(inNormal, Eye)>=0)
rimVal = 1- max(dot(inNormal, Eye),0);
_RampThreshold=1;
_RampSmooth=1;
rimVal = smoothstep(_RampThreshold-0.5*_RampSmooth,_RampThreshold+0.5*_RampSmooth,rimVal);
float NDL = dot(inNormal, inLightVec);
NDL = NDL > 0 ? 1 : 0;
vec4 rimColor=vec4(1,1,1,1)*rimVal*NDL;
outFragColor = vec4(inColor, 1.0)*(ambient + diffuse)+ISpecular+rimColor ;
}
实现效果如下:
三、描边
描边在日式卡通渲染中主要是为了将物体的轮廓显示出来,这样可以将物体与背景隔离,形成强烈的对比。
描边按实现方式可以分为以下三种:
- 基于视点的描边
- 基于过程几何方法的描边
- 基于边缘检测的描边
3.1 基于视点方向的描边
使用视点方向(view point) 和 表面法线(surface normal) 之间的点乘结果得到轮廓线信息。
点乘结果越接近于0,说明这个表面更大可能是在侧向的视角方向,则我们可以将其当作轮廓边缘进行描边。
- 优点:开销小,只需要一个Pass
效果差。
void main()
{
...
//rim
float rimVal = 0;
if(dot(inNormal, Eye)>=0)
rimVal = 1- max(dot(inNormal, Eye),0);
_RampThreshold=1;
_RampSmooth=1;
rimVal = smoothstep(_RampThreshold-0.5*_RampSmooth,_RampThreshold+0.5*_RampSmooth,rimVal);
float NDL = dot(inNormal, inLightVec);
NDL = NDL > 0 ? 1 : 0;
vec4 rimColor=vec4(1,1,1,1)*rimVal*NDL;
if(dot(inNormal, Eye)<0.1 && dot(inNormal, Eye)>=0.0)
outFragColor = vec4(0,0,0,1.0);
else
outFragColor = vec4(inColor, 1.0)*(ambient + diffuse)+ISpecular+rimColor ;
}
3.2 基于过程几何方法的描边
具体实现可参照:Vulkan_模板测试运用1(轮廓绘制)
基于过程几何方法的描边的基本步骤有以下两步:
①渲染正向表面(剔除背面)
②渲染背向表面(剔除正面)
使用双Pass来进行描边,分为Z-Bias和BackFacing两种。
3.1.1 Z-Bias
这个方法是通过在观察空间,将模型沿z轴移动然后绘制描边层来进行实现
-
实现
使用两个Pass,一个Pass绘制主体,一个Pass绘制描边。
-
特点
双Pass实现; 实现简单; 在某些视角下,存在瑕疵.
3.1.2 Back Facing
Back Facing是使用两个Pass实现的,其中一个Pass绘制本体,另一个Pass绘制沿法线向外扩张的描边。
-
实现
基本思想就是将顶点沿法线方向扩张,然后在其前方绘制物体本体。
-
特点
双Pass实现; 解决了Z-Bias在某些视角下描边位置偏移的问题
TIP:绘制描边时,需要将正面剔除,否则会产生对本体的遮挡。
- 存在的问题
描边的宽度会随着物体离相机的远近而变化,描边的宽度现在是相对世界空间不变的,这相机拉近后,描边就会变粗。
解决方案:转到NDC空间进行扩张
主要实现方式是将法线转到ndc空间,然后需要注意获得屏幕宽高比,否则描边边缘会出现不均匀的情况。
float4 nearUpperRight = mul(unity_CameraInvProjection, float4(1, 1, UNITY_NEAR_CLIP_VALUE, _ProjectionParams.y));
float aspect = abs(nearUpperRight.y / nearUpperRight.x);
3.3 使用边缘检测实现描边(基于图像检测)
- 主要思想
通过使用边缘检查算子对图像数据进行卷积操作来找出轮廓.
具体实现可参照:Vulkan-图像处理(卷积运用)
四、眼睛(未亲自实现仅为理论)
当眼睛被头发遮挡时,在日式卡通渲染中做法是将被头发遮挡的眼睛显示出来,这里的实现原理比较简单,主要使用了模板测试来进行。
4.1 实现思路
- 在进行眼睛渲染的Pass时,将模板值设置为某一固定值;
- 我们在模板测试成功/失败后都会将模板值改为1(当然你也可以指定其它值)。接着在对头发进行渲染的Pass内,对模板值进行比较,让模板值不等于1的片元通过,通过这种方式则可以渲染出头发遮挡下的眼睛;
4.2 本村线
本村线是由《罪恶装备》的TA提出来的内轮廓线方案。普通的手绘内轮廓线方案,在进行放大时会出现锯齿;
通过使用本村线的方案,在模型放大后也不会出现走样的问题。
本村线的原则
-
内轮廓线与u轴v轴平行
-
没有手绘的轮廓线
-
需要美术对UV进行拆分、排列
五、头发(未亲自实现仅为理论)
对于头发的渲染,这里使用的是各向异性的头发渲染着色模型Kajiya-Kay模型。通过使用合适的法线偏移贴图,我们可以达到日式卡通中W型头发高光的效果。
5.1 Kajiya-Kay模型
Kajiya-Kay模型是一个非常经典的头发渲染模型,《神秘海域》中头发的实现也是在这一渲染模型的基础上实现的。
5.2 Kajiya-Kay原理
实现这个渲染模型有一个基础的前提,就是头发的面的切线方向需要从发根指向发尾。有了上面这个基础,我们可以通过使用偏移贴图对切线进行偏移以获得抖动的高光。
5.2.1 Kajiya-Kay中的高光
Kajiya-Kay中高光的实现有别于Blinn-Phong中使用半程向量(H)和法线方向(N)的点积(HdotN)这种计算方式,而使用了半程向量(H)和发尾至发根方向(T)的夹角正弦值(sin(T,H))来进行高光的计算。根据三角函数的公式可以知道,sin(T,H)=dot(H,N)。
具体推导过程如下(参照下方向量图进行推导):
5.2.2 双高光项
在Kajiya-Kay模型中,我们使用双高光项进行叠加渲染,这是基于日常对于头发的一个观察:
Kajiya-Kay中高光项的计算主要有以下步骤:
- 将高光在发根->发尾方向上进行随机偏移
- 重复偏移高光的方法获得两个高光项
- 对两个高光项进行叠加
5.2.3 对高光进行偏移
这里会使用一张偏移贴图来获得不同位置的高光偏移量,通过高光偏移量与法线相乘后并与切线求和,我们就可以将高光进行偏移。
具体代码如下:
float3 ShiftTangent(float3 T, float3 N, float shift)
{
return normalize(T + N * shift);
}
偏移贴图可采用:
5.2.4 高光项的计算
高光项的计算和Blinn-Phong类似,也是进行一个Pow操作:
float StrandSpecular(float3 T, float3 V, float3 L, float exponent)
{
float3 H = normalize(L + V);
float dotTH = dot(T, H);
float sinTH = sqrt(1 - dotTH * dotTH);
float dirAtten = smoothstep(-1.0, 0.0, dotTH);
return dirAtten * pow(sinTH, exponent);
}
其中需要注意的是dirAtten这一项,他的作用是当T和H的夹角变化时,控制高光的衰减。他遵循以下规则:
- T,H夹角为钝角时,进行衰减
- T,H夹角为锐角时,不衰减
接下来是头发光照的函数,需要注意的是,其实公司中的T,传入的是副法线。
fixed4 HairLighting(float3 tangent, float3 normal, float3 lightDir, float3 viewDir, float2 uv)
{
fixed3 shiftColor = tex2D(_HairTex, uv);
float shiftTex = shiftColor.g;
float noise = shiftColor.b;
float3 t1 = ShiftTangent(tangent, normal, _PrimaryShift + shiftTex);
float3 t2 = ShiftTangent(tangent, normal, _SecondaryShift + shiftTex);
float3 specular = _SpecularColor1 * StrandSpecular(t1, viewDir, lightDir, _Glossiness1);
specular += _SpecularColor2 * StrandSpecular(t2, viewDir, lightDir, _Glossiness2) * noise;
fixed4 o;
o.rgb = specular;
return o;
}
通过以上的计算,可以获得下面的效果: