写在前面

我的博客讲过好几篇卡通渲染了,比如【Unity Shader实战】卡通风格的Shader(一)【Unity Shader实战】卡通风格的Shader(二)【NPR】漫谈轮廓线的渲染【Shader拓展】Illustrative Rendering in Team Fortress 2。后来,我搞了个所谓的NPR实验室,来实现一些论文里或者网络博客里讲到的NPR渲染算法,这里面包含了一些卡通风格的渲染。这篇文章主要想介绍一下这个项目里的一些卡通渲染的方法。包括:

  • 一个最常见的包含了卡通风格的漫反射+高光的场景。
  • 基于色调的卡通渲染。
  • 一种风格化的卡通高光的计算方法。

以下所有图片和代码均出自github上的NPR Labs项目,使用Unity 5.x进行实现,如果你有兴趣的话可以贡献或下载。下面如果出现代码的话均是相关的着色器代码(通常是片元着色器)。

最常见的卡通渲染

卡通渲染的特点通常有三个:一般物体轮廓处有黑色描边;漫反射呈现明显的色块,而不是渐变;高光区域通常是一块突变的白色亮块。

描边

卡通渲染的一个特点是描边。关于描边的方法可以参见【NPR】漫谈轮廓线的渲染一文,在后面的实现中,我们主要选择过程式几何轮廓渲染的方法,即使用两个Pass,第一个Pass只渲染背面,把法线扁平化后再沿着法线方向扩张顶点,使得背部区域可见,再把这部分区域输出成轮廓线颜色即可。主要代码如下:

  1. v2f vert (a2v v) {
  2. v2f o;
  3. float4 pos = mul(UNITY_MATRIX_MV, v.vertex);
  4. float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
  5. normal.z = -0.5;
  6. pos = pos + float4(normalize(normal), 0) * _Outline;
  7. o.pos = mul(UNITY_MATRIX_P, pos);
  8. return o;
  9. }
  10. float4 frag(v2f i) : SV_Target {
  11. return float4(_OutlineColor.rgb, 1);
  12. }

第二个Pass即可以进行正常的渲染流程。这种方法简单而且对大部分模型都有比较好的健壮性,缺点是不适用于正方体这样扁平的表面和模型。

漫反射和高光反射

之前讲过,卡通渲染的漫反射呈现明显的色块,而不是渐变。这可以通过对法线和光源方向的点乘结果进行范围判断,使结果划分到固定的几个值(一般取三或四个值,模拟三层或四层渐变)。例如我们可以在片元着色器中这样写:

  1. fixed diff = dot(worldNormal, worldLightDir);
  2. diff = diff * 0.5 + 0.5;
  3. if (diff < _DiffuseSegment.x) {
  4. diff = _DiffuseSegment.x;
  5. } else if (diff < _DiffuseSegment.y) {
  6. diff = _DiffuseSegment.y;
  7. } else if (diff < _DiffuseSegment.z) {
  8. diff = _DiffuseSegment.z;
  9. } else {
  10. diff = _DiffuseSegment.w;
  11. }

其中_DiffuseSegment为(0.1, 0.3, 0.6, 1.0)。在上面的代码中,我们首先计算半兰伯特值diff,然后判断它的范围并进行修改,最后,整个模型表面的diff值实际只有4个不同的值,对应了_DiffuseSegment。然后,我们再根据这个diff值进行漫反射颜色的计算即可。

  1. fixed3 texColor = tex2D(_MainTex, i.uv).rgb;
  2. fixed3 diffuse = diff * _LightColor0.rgb * _DiffuseColor.rgb * texColor;

上面做法的问题在于,在分段的边界处会有明显的锯齿,这是因为从值_DiffuseSegment.x到_DiffuseSegment.y这样的变化是突变的。为了进行抗锯齿,我们可以使用fwidth函数:

  1. fixed w = fwidth(diff) * 2.0;
  2. if (diff < _DiffuseSegment.x + w) {
  3. diff = lerp(_DiffuseSegment.x, _DiffuseSegment.y, smoothstep(_DiffuseSegment.x - w, _DiffuseSegment.x + w, diff));
  4. // diff = lerp(_DiffuseSegment.x, _DiffuseSegment.y, clamp(0.5 * (diff - _DiffuseSegment.x) / w, 0, 1));
  5. } else if (diff < _DiffuseSegment.y + w) {
  6. diff = lerp(_DiffuseSegment.y, _DiffuseSegment.z, smoothstep(_DiffuseSegment.y - w, _DiffuseSegment.y + w, diff));
  7. // diff = lerp(_DiffuseSegment.y, _DiffuseSegment.z, clamp(0.5 * (diff - _DiffuseSegment.y) / w, 0, 1));
  8. } else if (diff < _DiffuseSegment.z + w) {
  9. diff = lerp(_DiffuseSegment.z, _DiffuseSegment.w, smoothstep(_DiffuseSegment.z - w, _DiffuseSegment.z + w, diff));
  10. // diff = lerp(_DiffuseSegment.z, _DiffuseSegment.w, clamp(0.5 * (diff - _DiffuseSegment.z) / w, 0, 1));
  11. } else {
  12. diff = _DiffuseSegment.w;
  13. }

在上面的代码中,我们首先使用fwidth函数计算了邻域内diff的梯度值w,我们将据此在分段的边界处的+-w范围内进行渐变混合,这个混合值既可以使用smoothstep函数也可以通过clamp函数计算而得。

对高光区域的渲染也是类似的。我们首先计算得到高光反射因子,再判断它的范围,如果超过了某个值,就把值直接设为1,对应了高光区域,否则值为0,没有任何高光。同样,为了进行抗锯齿,我们通过需要使用fwidth进行边界混合。主要代码如下:

  1. fixed spec = max(0, dot(worldNormal, worldHalfDir));
  2. spec = pow(spec, _Shininess);
  3. w = fwidth(spec);
  4. if (spec < _SpecularSegment + w) {
  5. spec = lerp(0, 1, smoothstep(_SpecularSegment - w, _SpecularSegment + w, spec));
  6. } else {
  7. spec = 1;
  8. }
  9. fixed3 specular = spec * _LightColor0.rgb * _SpecularColor.rgb;

至此,我们就完成了一个最简单(或者说是原始)的卡通渲染。在NPR实验室项目中,这对应的场景是AntialiasedCelShadingScene:

基于色调的卡通渲染

在上面的实现中,我们是在着色器中判断漫反射因子的范围来实现大色块的渲染的。在实际的游戏制作中,我们一般是不会使用这种方法的,一方面是性能比较耗,更重要的是可控性比较弱。更实用的方法是用一张色调图(渐变图)来模拟漫反射的渐变。这种理论其实是由1998年的A Non-Photorealistic Lighting Model for Automatic Technical Illustration,这篇论文提出来的。作者提出,色调可以由混合两个颜色,冷调颜色kcool和暖调颜色kwarm来得到,公式是:

I=(1+l⋅n2)kcool+(1−1+l⋅n2)kwarm

作者在论文中提到了使用蓝色kblue=(0,0,b),b∈[0,1]来模拟冷色调,使用黄色kyellow=(y,y,b),y∈[0,1]来模拟黄色调,来实现从冷到暖的色调变化,其中b和y都是用户可调参数。为了更加真实的模拟,作者还提出和模型本身的漫反射颜色kd进行混合来得到最终的冷暖色调,混合公式如下。

kcool=kblue+αkdkwarm=kyellow+βkd

其中,α和α都是用户可调的参数。

NPR实验室项目中,场景ToneBasedShadingScene实现了上述的渲染方法:

当然,这里只是为了实现下论文中的方法。在实际渲染中,我们会直接使用一张渐变纹理进行采样,来得到漫反射颜色。

风格化的卡通高光

最后,我们来讲一下如何对卡通高光进行风格化的渲染。在之前的实现中,我们通过判断高光因子的范围来得到一块纯白的高光区域。但很多卡通动画中,高光区域是非常风格化的。2003年的Stylized highlights for cartoon rendering and animation论文中提到了一种算法来风格化高光区域,实现对高光区域的平移、旋转、缩放、分块和方块化。如下图所示:

它的主要思想是对Blinn-Phong模型中的半向量(half vector)进行一些修改操作,然后再判断半向量和法线方向点乘结果的范围,实现高光区域Hϵ:

Hϵ={p∈S|N(p)⋅H(p)>1−ϵ}

因此,重点就是如何对半向量H进行平移、缩放、旋转、分块和方块化的计算。论文考虑切线空间下的点p和它的半向量H。

定义平移操作t(H):

H′=H+αdu+βdvt(H)=H′||H′||

其中du和dv分别是切线空间下的x轴和y轴。其实说白了就是把切线空间下的半向量的x和y分量加上某个参数值,再进行归一化。着色器代码为:

  1. // Translation
  2. tangentHalfDir = tangentHalfDir + fixed3(_TranslationX, _TranslationY, 0);
  3. tangentHalfDir = normalize(tangentHalfDir);

其中_TranslationX和_TranslationY是用户可调参数,用于控制x和y方向的高光区域平移程度。

旋转操作r(H)其实就是就是对切线空间下的半向量和旋转矩阵进行相乘,实现对某个坐标轴的旋转。着色器代码为:

  1. // Ratation
  2. float xRad = _RotationX * DegreeToRadian;
  3. float3x3 xRotation = float3x3(1, 0, 0,
  4. 0, cos(xRad), sin(xRad),
  5. 0, -sin(xRad), cos(xRad));
  6. float yRad = _RotationY * DegreeToRadian;
  7. float3x3 yRotation = float3x3(cos(yRad), 0, -sin(yRad),
  8. 0, 1, 0,
  9. sin(yRad), 0, cos(yRad));
  10. float zRad = _RotationZ * DegreeToRadian;
  11. float3x3 zRotation = float3x3(cos(zRad), sin(zRad), 0,
  12. -sin(zRad), cos(zRad), 0,
  13. 0, 0, 1);
  14. tangentHalfDir = mul(zRotation, mul(yRotation, mul(xRotation, tangentHalfDir)));

其中_RotationX、_RotationY和_RotationZ是用于控制半向量绕x轴、y轴和z轴的旋转角度。实际上,绕x轴和y轴的旋转和平移效果很类似,我们通常只需要调整z轴的旋转即可。

接着是缩放操作。例如,绕x轴的缩放为:

H′=H−δ(H⋅du)du,δ∈[0,1]s(H)=H′||H′||

通过控制δ的值,我们可以让半向量更加接近/远离法线方向,从而实现对高光区域在特定方向上的缩放。例如,当δ从0逐渐变大时,半向量的x分量逐渐变小,从而在x方向上更靠近法线方向,高光区域在x方向上变大。通过这种方法,我们沿任意方向放缩高光区域,但在实现中我只实现了对x方向和y方向的缩放。着色器代码如下:

  1. // Scale
  2. tangentHalfDir = tangentHalfDir - _ScaleX * tangentHalfDir.x * fixed3(1, 0, 0);
  3. tangentHalfDir = normalize(tangentHalfDir);
  4. tangentHalfDir = tangentHalfDir - _ScaleY * tangentHalfDir.y * fixed3(0, 1, 0);
  5. tangentHalfDir = normalize(tangentHalfDir);

其中_ScaleX和_ScaleY用于控制高光区域在x方向和y方向上的缩放,范围都是0到1。

下面是分块操作(split)。分块操作会把一个高光区域分成两个分离的区域,它的公式其实就是缩放公式的修改版:

H′=H−γ1sgn[(H⋅du)]du−γ2sgn[(H⋅dv)]dvspl(H)=H′||H′||

其中,sgn[x]操作会判断x的符号,如果x为负返回-1,否则返回1。和缩放操作不同,分块操作会把半向量沿着不同方向让它们远离法线。着色器代码如下:

  1. // Split
  2. fixed signX = 1;
  3. if (tangentHalfDir.x < 0) {
  4. signX = -1;
  5. }
  6. fixed signY = 1;
  7. if (tangentHalfDir.y < 0) {
  8. signY = -1;
  9. }
  10. tangentHalfDir = tangentHalfDir - _SplitX * signX * fixed3(1, 0, 0) - _SplitY * signY * fixed3(0, 1, 0);
  11. tangentHalfDir = normalize(tangentHalfDir);

其中_SplitX和_SplitY用于控制高光区域在x方向和y方向上的分离程度。

最后一个操作是方块化操作(squaring)。这是最复杂的一个操作,公式如下:

θ=min(cos−1(H⋅du),cos−1(H⋅dv)),sqrnom=sin(2θ)n,H′=H−σ×sqrnom×((H⋅du)du+(H⋅dv)dv),sqr(H)=H′||H′||

不过按照上面的公式计算我总是无法调整得到希望的方块形……我稍微更改了下,不计算两个角度的最小值,而是同时使用两个角度。着色器代码如下:

  1. // Square
  2. float sqrThetaX = acos(tangentHalfDir.x);
  3. float sqrThetaY = acos(tangentHalfDir.y);
  4. fixed sqrnormX = sin(pow(2 * sqrThetaX, _SquareN));
  5. fixed sqrnormY = sin(pow(2 * sqrThetaY, _SquareN));
  6. tangentHalfDir = tangentHalfDir - _SquareScale * (sqrnormX * tangentHalfDir.x * fixed3(1, 0, 0) + sqrnormY * tangentHalfDir.y * fixed3(0, 1, 0));
  7. tangentHalfDir = normalize(tangentHalfDir);

其中_SquareScale控制方块的大小,_SquareN控制方块的尖锐程度。方块化的调整很tricky,一不小心就会出现些不好的效果。

在实现过程中,这些操作也是有顺序的,通常实现顺序是:缩放,旋转,平移,分块,方块化。

NPR实验室项目中,场景StylizedHighlightsScene实现了上述的渲染方法:

写在最后

NPR实验室项目目前还包括了一些铅笔风格的实现,有时间再总结吧。希望大家有所收获,就这样~

【NPR】卡通渲染的更多相关文章

  1. Unity Shader NPR 卡通渲染

    卡通渲染的主要原理包含两个方面: 1.轮廓线的描边效果 2.模型漫反射离散和纯色高光区域的模拟 描边: 描边的实现方法采用将模型的轮廓线顶点向法线(或顶点)的方向扩展一定的像素得到.也可通过边缘检测( ...

  2. Unite 2018 | 《崩坏3》:在Unity中实现高品质的卡通渲染(上)

    http://forum.china.unity3d.com/thread-32271-1-1.html 我们已经发布了Unite 2018 江毅冰的<发条乐师>.Hit-Point的&l ...

  3. GGXX的卡通渲染实现 真的好变态......

    最近在youtube上看了GDC,学了很多东西,最让我震撼的就是ggxx的卡通渲染了.感慨一下,想要用3D做出二次元的效果,真的不容易.现记录一些要点: 1)不要使用normal map来做cel-s ...

  4. Unite 2018 | 《崩坏3》:在Unity中实现高品质的卡通渲染(下)

    http://forum.china.unity3d.com/thread-32273-1-1.html 今天我们继续分享米哈游技术总监贺甲在Unite Beijing 2018大会上的演讲<在 ...

  5. 基于Unity 5的次世代卡通渲染技术 -- Unite 2017 米哈游总监贺甲分享实录

    在5月12日Unite2017开发者大会上,米哈游技术总监兼美术指导贺甲进行了主题为次世代卡通渲染的演讲.一下为详细分享内容: 大家好,首先自我介绍一下,我叫贺甲,在米哈游担任技术总监和美术指导工作, ...

  6. Unity Shader 卡通渲染 基于退化四边形的实时描边

    从csdn转移过来,顺便把写过的文章改写一下转过来. 一.边缘检测算法 3D模型描边有两种方式,一种是基于图像,即在所有3D模型渲染完成一张图片后,对这张图片进行边缘检测,最后得出描边效果.一种是基于 ...

  7. Unity酱~ 卡通渲染技术分析(一)

    前面的话 unitychan是日本unity官方团队提供的一个Demo,里面有很好的卡通渲染效果,值得参考学习 上图是我整理出来的shader结构,可以看到Unity娘被拆分成了很多个小的部件,我想主 ...

  8. Unity酱~ 卡通渲染技术分析(二)

    前面的话 上一篇Unity酱~ 卡通渲染技术分析(一) 写了CharaMain.cginc,服装的渲染是怎么实现的.这篇来分析一下头发跟皮肤的实现 头发 本来以为unitychan的头发会有各向异性的 ...

  9. Unity——卡通渲染实现

    效果展示: 原模型: 一.简单分析 卡通渲染又叫非真实渲染(None-Physical Rendering-NPR),一般日漫里的卡通风格有几个特点: 1.人物有描边 2.有明显的阴影分界线,没有太平 ...

随机推荐

  1. [HNOI 2016]大数

    Description 题库链接 给你一个长度为 \(n\) ,可含前导零的大数,以及一个质数 \(p\) . \(m\) 次询问,每次询问你一个大数的子区间 \([l,r]\) ,求出子区间中有多少 ...

  2. codefroces 612E Square Root of Permutation

    A permutation of length n is an array containing each integer from 1 to n exactly once. For example, ...

  3. ●BOZJ 4456 [Zjoi2016]旅行者

    题链: http://www.lydsy.com/JudgeOnline/problem.php?id=4456 题解: 分治好题.大致做法如下:对于一开始的矩形区域,过较长边的中点把矩形区域分为两个 ...

  4. Spring源码分析(一)--BeanProcessor

    一.何谓BeanProcessor BeanProcessor是SpringFramework里非常重要的核心接口之一,我先贴出一段源代码: /* * Copyright 2002-2015 the ...

  5. quartzJob 例子

    KpiOfPoorQualityJob.javapackage com.eastcom_sw.inas.workorder.quartzJob.kpi; import net.sf.json.JSON ...

  6. Feign报错Caused by: com.netflix.client.ClientException: Load balancer does not have available server for client

    问题描述 使用Feign调用微服务接口报错,如下: java.lang.RuntimeException: com.netflix.client.ClientException: Load balan ...

  7. Python中模块json与pickle的功能介绍

    json & pickle & shelve 1. json的序列化与反序列化 json的使用需要导入该模块,一般使用import json即可. json的序列化 方法1:json. ...

  8. js打印小结

    <script type="text/javascript"> //打印必备参数 var hkey_root,hkey_path,hkey_key; hkey_root ...

  9. 9.QT-标准对话框

    Qt提供的可复用的标准对话框,全部继承自QDialog类,如下图所示: QMessageBox:信息对话框,用于显示信息.询问问题等: QFileDialog:文件对话框 QColorDialog:颜 ...

  10. sqlserver 判断字段是否为空字符串或者null

    isnull(f.mzm,'')<>'' 不为null且不为‘’ not(f.mzm is null) 不为null