Demo的卡通渲染方案

  欢迎参与讨论,转载请注明出处。

前言

  本篇文章按理来说在三月便该发布了,因为插队原因延宕至今,不过好饭不怕晚,干就完了奥利给!阅读本文最好拥有一定的图形学知识,当然看个热闹也是好的。
  游戏画面的风格是一开始便要定下的大事,这在古法2D主要通过素材本身及后期调色决定,没有太多文章可作。而在现代游戏(尤其是3D)则会通过Shader在原本的元素上进行加料,如通过基于物理的渲染(PBR)将模型凸显出金属、石头、布料等材质倾向。而在早期为了凸显3D模型的立体感,一般会采用经验总结出来的冯氏光照模型(Blinn-Phong),这也是许多3D软件的默认方案,那将会让我们的模型长成这样:
0
  嗯,这有够雕塑风的,让我想起了当初名震一时的猴赛雷,有着异曲同工之妙:
1
  由此可见,对于讲究卡通风格的游戏,这种通用的光照模型肯定得枪毙,于是本文才会诞生。对于这类非写实方向的渲染方案,业界称之为NPR。而往下细分则是日式卡通渲染,其中佼佼者当属《罪恶装备》系列,而《崩坏3rd》也是不少人在这方面的启蒙者。当然美术这一块没有绝对的风格一致,渲染也不例外,所以Demo里的卡通渲染方案乃是个人的方案,不代表业界的标准实现与效果。
  Demo基于Unity2019.3开发,渲染管线为URP7.3.1,采用直接编写Shader的方式(HLSL),将一一介绍其中要点。本文所谓的卡通效果以日式2D赛璐璐风格为准,不论厚涂之类的风格。

着色

  首先我们先抛开一切:冯氏光照不好那咱们就是了。直接把贴图显示了,什么料都不要加。

1
2
3
// 根据uv坐标获取对应贴图上的颜色
half4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);

2
  嗯,虽然很原始,但好歹没那股恶心感了,把投影也加上:

1
2
3
4
// 根据渲染管线提供的shadowCoord获取光照信息,并计算出投影颜色
Light mainLight = GetMainLight(shadowCoord);
color *= mainLight.color * (mainLight.distanceAttenuation * mainLight.shadowAttenuation);

3
  哎呀,有了投影瞬间立体起来了,开始有《塞尔达传说:风之杖》内味了:
4
  要加投影记得添加Pass:ShadowCaster,并且获取光照信息也需要开启一定的宏,这些并非本文重点,详情请查阅URP的Shader实现。
  但只是如此还不够:颜色太鲜艳了,看久了会累。那么有两种方案:调色与着色,调色则是进行总体的颜色调节,使之不要这么鲜艳,着色则是根据模型面对光的吸收度决定明暗。这里还是选择着色:它将会增强模型的立体感。
  这里说的着色其实就是冯氏光照中的漫反射(Diffuse):当光照射到非平面的物体上,将根据与光的夹角决定吸收度(越是与光垂直的面越亮)。而在3D模型中,每个模型面都会往上发射一条射线,也就事实上构成了一条垂直于平面向量,这在数学中称之为法线(Normal)。我们可以使用向量点积(Dot)获取法线与光照方向之间的夹角,以此决定模型面的光亮程度。
5

1
2
3
half NDotL = dot(normal, lightDir); // 计算法线与光照方向的夹角系数
NDotL = saturate(NDotL); // 保证系数在0-1
color *= NDotL;

6
  啊这,这不是跟一开始差不多么?这是当然的,因为一开始便是冯氏光照的方案。其漫反射的思想其实并无问题,但原罪在于过渡太丰富了,每个模型面与光的夹角都不同,导致颜色都不同。整个模型看起来就过于立体,以至于产生了雕塑感。
  而在日式2D卡通的世界里(尤其是赛璐璐),着色并不会有太详细的过渡,只是到了某个角度统一涂暗,反之为亮,最多在两者之间加点过渡而已。那么便基于此思想进行改造即可:

1
2
3
4
5
6
7
8
9
10
11
12
half NDotL = dot(normal, lightDir); // 计算法线与光照方向的夹角系数
// 根据_DiffuseRange约束系数,输出0-1的值
// 由于使用了smoothstep,在接近_DiffuseRange上下限时会做柔滑处理,使之产生过渡感
// _DiffuseRange的参考值为0.5, 0.7
half v = smoothstep(_DiffuseRange[0], _DiffuseRange[1], NDotL);
// 根据根据v的值决定输出_LightRange范围内的值
// _LightRange的参考值为0.9, 1
v = lerp(_LightRange[0], _LightRange[1], v);
color *= v;

7
  还不错,这下便为模型划分了明暗,并在两者之间做了过渡,这种方式称之为二值化。着色并没有采用很明显的暗色,只是想凸显一点立体感,以及让画面更柔和,不那么刺眼罢了。当然目前可以说是非常不明显了,这是有原因的,且待后续调色。

描边

  接下来需要补上日式2D卡通不可或缺的一部分:描边(Outline),描边有助于划分物体,明确空间上的层次,并提供一定的风味。
  关于描边的实现方式,业界主要有模型多画一遍并将边缘外扩以及屏幕后处理的方案。前者方案在日式游戏较为流行,优点在于实现简单,性能也还算过得去,缺点是必须开抗锯齿不然没眼看。后者实现方式多样,并且根据实现方式能达到不一样的效果(如一定程度的内描边),但有些更适合搭配延迟渲染(Deferred Rendering),而这代表着对显卡带宽与光照方案有要求。
  另外在显示方案上也有区别,有追求任何缩放下描边大小不变的,也有自然派的。有让描边纯色的,也有要让描边根据贴图颜色决定的。本人采用的是模型外扩、自然缩放、根据贴图颜色决定的描边方案。
  多显示一遍模型在Unity增加一个Pass即可,并且开启正面剔除(只显示背面,不然会干扰到正常模型)。并且在顶点着色器对模型顶点进行外扩,外扩的方向由所在模型面的法线决定。而颜色方面则在片元着色器根据贴图颜色进行置暗显示即可:

1
2
3
4
5
6
7
8
9
10
// 顶点着色器
half4 viewPos = mul(UNITY_MATRIX_MV, input.positionOS); // 将顶点从模型空间转为观察空间
half3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, input.normalOS); // 同上,将法线转为观察空间
viewPos += float4(normalize(normal), 0) * 0.0075; // 顶点沿法线外扩
output.positionCS = mul(UNITY_MATRIX_P, viewPos); // 将顶点从观察空间转为裁剪空间
output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); // 提取uv
return output;
1
2
3
4
// 片源着色器
color *= SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); // 提取贴图颜色
return color * 0.3; // 压暗

8

  果不其然,没有抗锯齿的话就很搓,跟早期的跑跑卡丁车似的。安排一波MSAA8x:
9
  哎,这就舒服多了,当然实际上由于小泥人的关系,4x和8x实际上看不出区别,而2x也算可以接受的效果,这么看来能耗也还好。当然关于描边实际上还有内描边这个大题,但小泥人不需要这么丰富的细节,这就很舒服。

发光

  目前模型的显示还欠缺一些发光的元素,如一般头发和武器会有一些高光效果。这在冯氏光照称之为镜面光照(Specular):本质上与漫反射一样,只是由视角方向与光照方向相加,并与法线做点积获得两者的夹角系数,如此便可实现根据摄像机与光照运动结合决定模型高光的位置。
  当然仅此而已是不够的,显而易见仅此而已的话将会如漫反射一般范围很大,而高光实际上只需要一点即可。实际上会将之范围缩小:

1
2
3
4
5
6
half3 halfVec = normalize(lightDir + viewDir); // 将摄像头方向与光照方向相加
half NdotH = dot(normal, halfVec); // 与法线点积,获取夹角系数
NdotH = saturate(NdotH); // 保证在0-1
half v = pow(NdotH, _Smoothness); // 缩小夹角系数的值,由于NdotH在0-1,所以pow后会变得更小,_Smoothness参考值为8-64
color += color * v; // 在原有颜色的基础上叠加

10
  与之前一样,这样的高光过渡太强了,不够卡通,将之二值化:

1
2
3
4
5
half v = pow(NdotH, _Smoothness); // 缩小夹角系数的值,由于NdotH在0-1,所以pow后会变得更小,_Smoothness参考值为8-64
v = step(_SpecularRamp, v); // 小于_SpecularRamp的值将为0,反之为1
v = v * _SpecularStrength; // 定义高光强度,参考值为0.2
color += color * v; // 在原有颜色的基础上叠加

11
  这样的高光就更有手绘的感觉了,牛屎一块。但很显然对于头发而言光是一块牛屎高光是不够的,让美术自由的进行创作显然是更好的方案。于是引入了发光贴图(Emission),其本身很简单:就是在最后把发光贴图的内容显示出来即可。而之所以要单独划分贴图而不是画死在原贴图,在于要自由的控制透明度甚至曝光,以及让发光参与单独的光照运算(与高光类似的方法,摄像机视角与光照方向相加后与法线点积)。
12

  到了目前仍缺一个日式2D卡通的一个特性:边缘光(Rim),一般为了表达物体处于光亮的环境下,属于光溢出的一种表达,有助于提升画面的层次感。实现原理也很简单:视角方向与法线点积,根据夹角系数取得当前视角下的模型边缘部分,为之加光即可。

1
2
3
4
5
6
7
8
half VDotN = dot(viewDir, normal); // 视角方向与法线点积,获取夹角系数
VDotN = 1 - saturate(VDotN); // 取反,方便计算
half v = smoothstep(_RimRange[0], _RimRange[1], VDotN); // 与漫反射部分类似,做二值化,参考值为0.4-1
v = step(0.5, v); // 小于0.5的部分都不要了
v = v * _RimStrength; // 设定边缘光强度,参考值为0.1
color += color * v; // 叠加

13
  发光的构成大致如此,目前也许看起来不够明显,实是尚未调色所致,且看下文。

调色

  先来看看目前的效果:
14
  首先是整体颜色风格不符合主题,这个场景属于有着岩浆的密室,应该符合昏暗以及灼热的色调,使用Split Toning进行调色:
15
  嗯,至少色调上像样了,但还是缺乏灼热的感觉,上Bloom看看:
16
  哎呀,看着只是稍微亮了点的样子,那是因为Bloom需要配合HDR使用,将颜色突破0-1的限制下进行运算,才能做到光溢出的效果:
17
  唔……这溢出的实在是有限,因为目前还处于Linear颜色空间,显示器对于颜色会进行处理,使得颜色之间的区间变小(明暗不明显),需要转成Gamma才能抵消之:
18
  成了,如此便得出了昏暗且灼热的场景风格,高对比度(亮者更亮、暗者更暗)的画面。

后记

  这算是本人进入图形渲染的一个里程碑,感觉这的确是个美术活。技术不过是让你能进入赛道罢了,真正决定效果的还得看美术的理念。