【01】从零开始的卡通渲染-描边篇

序言:

一直对卡通渲染非常感兴趣,前后翻找了不少的文档,做了一些工作。前段时间《从零开始》的手游上线了,试着渲染了一下的其中模型,觉得效果很不错。打算写一个专栏记录其中的渲染技术。在后面的篇章中也想展示一下各个项目中卡通渲染技术的变迁,以及讨论未来的一些发展方向。 卡通渲染属于非真实感渲染(Non-photorealistic rendering,简称NPR)。对应的还有真实感渲染(Photorealistic rendering)。后者旨在渲染真实感的画面,而前者则追求更加有艺术感的画面效果,例如手绘风格的画面。 NPR也有各种各样的类型。比如像油画,铅笔画,水墨画风格的画面。这里主要探讨像日本动画那样的卡通渲染风格,目前一般称之为Cel Shading。卡通渲染在日本那边很早就在主机游戏上使用,经过了很多尝试和变迁,最终在《GUILTY GEAR Xrd》系列游戏达到了非常不错的水准。国内的厂商在吸收了日本同行的经验以后,也制作了非常好的作品,并且在此之上创新,提出了更多的解决方案。 说了很多,现在回归正题。描边是卡通渲染的一个非常重要的主题。目前比较流行的描边方法有两种,一个是通过两次绘制,一次绘制角色,一次绘制描边。还有一种是基于后处理的描边。基于后处理的描边相对不容易定制,比较适用于对复杂场景进行描边。这里讲述通过2次绘制来绘制描边的方法。在《GUILTY GEAR Xrd》中称其为Back Facing法。

Back facing描边法

基本思路是通过两次绘制,第一次绘制角色,第二次绘制描边。绘制描边的时候,在顶点着色器将顶点沿着法线方向位移一段距离,使得模型轮廓放大,渲染作为描边。同时描边绘制时使用cull front。这样描边和角色重叠的部分会因为不能通过深度检测而cull掉,保证描边不会遮挡角色。两次绘制颠倒顺序也是可以的,不过后绘制描边,可以通过深度检测过滤掉很多描边绘制的像素,效率会更好。这里先实现最简单的方法,然后逐步进行优化。
    Shader "Unlit/Ouline"{Properties{_OutlineWidth ("Outline Width",Range(0.01,1))=0.24_OutLineColor ("OutLine Color",Color)=(0.5,0.5,0.5,1)}SubShader{Tags {"RenderType"="Opaque"}pass{Tags {"LightMode"="ForwardBase"}Cull BackCGPROGRAM#pragma vertex vert#pragma fragment frag#include "UnityCG.cginc"float4vert(appdata_basev):SV_POSITION{returnUnityObjectToClipPos(v.vertex);}half4frag():SV_TARGET {returnhalf4(1,1,1,1);}ENDCG}Pass{Tags {"LightMode"="ForwardBase"}Cull FrontCGPROGRAM#pragma vertex vert#pragma fragment frag#include "UnityCG.cginc"half_OutlineWidth;half4_OutLineColor;structa2v{float4vertex :POSITION;float3normal :NORMAL;float2uv :TEXCOORD0;float4vertColor :COLOR;float4tangent :TANGENT;};structv2f{float4pos :SV_POSITION;};v2fvert (a2vv){v2fo;UNITY_INITIALIZE_OUTPUT(v2f,o);o.pos =UnityObjectToClipPos(float4(v.vertex.xyz +v.normal *_OutlineWidth *0.1,1));//顶点沿着法线方向外扩returno;}half4frag(v2fi):SV_TARGET {return_OutLineColor;}ENDCG}}}
现在我们用Unity预设的球体进行渲染

修正摄像机距离问题

现在我们将摄像机拉近,发现摄像机拉近后,描边变得很粗 这是因为描边的宽度现在是相对世界空间不变的,这相机拉近后,显示就会变粗。我们期望无论摄像机拉近拉远,描边的粗细都能不变。要解决这个问题,可以通过将法线外扩的大小调整为使用NDC空间的距离进行外扩。这里参考 这篇文章 对代码进行一些修改。
    v2fo;UNITY_INITIALIZE_OUTPUT(v2f,o);float4pos =UnityObjectToClipPos(v.vertex);float3viewNormal =mul((float3x3)UNITY_MATRIX_IT_MV,v.normal.xyz);float3ndcNormal =normalize(TransformViewToProjection(viewNormal.xyz))*pos.w;//将法线变换到NDC空间pos.xy +=0.01*_OutlineWidth *ndcNormal.xy;o.pos =pos;returno;
结果似乎有些问题,描边的两边粗,上下细。这是因为NDC空间的xy是范围是[0,1]。但是我这里的窗口分辨率是16:9,所以直接用NDC空间的距离外扩,不能适配宽屏窗口。所以需要根据窗口的宽高比再进行修正。这里再对描边进行修改
    v2fo;UNITY_INITIALIZE_OUTPUT(v2f,o);float4pos =UnityObjectToClipPos(v.vertex);float3viewNormal =mul((float3x3)UNITY_MATRIX_IT_MV,v.normal.xyz);float3ndcNormal =normalize(TransformViewToProjection(viewNormal.xyz))*pos.w;//将法线变换到NDC空间float4nearUpperRight =mul(unity_CameraInvProjection,float4(1,1,UNITY_NEAR_CLIP_VALUE,_ProjectionParams.y));//将近裁剪面右上角位置的顶点变换到观察空间floataspect =abs(nearUpperRight.y /nearUpperRight.x);//求得屏幕宽高比ndcNormal.x *=aspect;pos.xy +=0.01*_OutlineWidth *ndcNormal.xy;o.pos =pos;returno;
现在描边可以正确显示,而且无论摄像机的远近,描边的粗细可以保持不变了。

修正不光滑物体断边问题

之前我们渲染了unity的预制球体,现在我们换成预制的立方体试一下。 嗯…四个角的描边都断开了。这方案不行,Pass,放弃,(摔)。改用后处理描边吧。
咳…因为这个模型每个面的顶点的法线都垂直于这个平面。所以描边的外扩也是垂直于平面,当模型有转角的情况下,描边就会像这样裂开。Back facing的描边方法会有这样的问题。困扰了我一段时间,后来看到一个叫Toony Colors Pro的Unity插件,有了比较好的解决方法。 要解决这个问题,需要对模型外扩使用的法线数据进行修改。这里需要将在相同位置顶点的法线数据,进行平均计算,将算出来的新法线写入模型切线数据中。然后使用这个切线数据进行法线外扩。至于为什么要写到切线数据里,这是因为只有法线和切线数据会随着骨骼动画而改变。所以如果渲染的是有骨骼动画的角色,写入切线数据里就不用做额外处理,计算上简单一些。如果碰到了角色使用法线贴图或者各项异性材质这种需要原始切线数据的情况,那么可以先把平均法线转换到切线空间,再保存到UV或者顶点颜色上。计算的时候,从切线空间把平均法线数据还原到世界空间,计算上会稍微麻烦一点。不过因为切线空间也是随骨骼动画改变的,所以这个方法的结果也是正确的。这里我写了一个编辑器工具,完成对mesh数据的添加。
    publicclassPlugTangentTools{[MenuItem("Tools/模型平均法线写入切线数据")]publicstaticvoidWirteAverageNormalToTangentToos(){MeshFilter[]meshFilters =Selection.activeGameObject.GetComponentsInChildren<MeshFilter>();foreach(varmeshFilter inmeshFilters){Meshmesh =meshFilter.sharedMesh;WirteAverageNormalToTangent(mesh);}SkinnedMeshRenderer[]skinMeshRenders =Selection.activeGameObject.GetComponentsInChildren<SkinnedMeshRenderer>();foreach(varskinMeshRender inskinMeshRenders){Meshmesh =skinMeshRender.sharedMesh;WirteAverageNormalToTangent(mesh);}}privatestaticvoidWirteAverageNormalToTangent(Meshmesh){varaverageNormalHash =newDictionary<Vector3, Vector3>();for(varj =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;}}varaverageNormals =newVector3[mesh.vertexCount];for(varj =0;j <mesh.vertexCount;j++){averageNormals[j]=averageNormalHash[mesh.vertices[j]];}vartangents =newVector4[mesh.vertexCount];for(varj =0;j <mesh.vertexCount;j++){tangents[j]=newVector4(averageNormals[j].x,averageNormals[j].y,averageNormals[j].z,0);}mesh.tangents =tangents;}}
同时描边的方法里,改为使用切线数据作为外扩数据。
    float3viewNormal =mul((float3x3)UNITY_MATRIX_IT_MV,v.tangent.xyz);
现在这个立方体的模型也可以正确的描边了。不过这个方法只是临时修改了mesh数据,关闭Unity就丢失了。如果要将计算出的切线数据保存下来的话。一个可行的方案是使用FBX的SDK来编写工具,将计算出的切线数据写入模型里。我们试着用两种方式对角色进行描边来对比表现。 对比可以看到,使用新的法线数据进行描边,模型描边断边的问题少了很多。
然后再添加一点细节

顶点色的使用

能多放入一些数据,就能增加更多的效果。关于模型顶点色当然也不能浪费。在《GUILTY GEAR Xrd》中使用模型顶点颜色的四个通道,对模型描边的粗细、显隐、相机距离缩放等进行了精细的控制。当然顶点数据还可以用来做很多其他的事情,这取决于想要实现的效果,和美术制作的难度。在本篇中,我们使用顶点色控制描边的粗细和颜色。对代码进行一些修改。
    v2fvert (a2vv){v2fo;UNITY_INITIALIZE_OUTPUT(v2f,o);float4pos =UnityObjectToClipPos(v.vertex);float3viewNormal =mul((float3x3)UNITY_MATRIX_IT_MV,v.tangent.xyz);float3ndcNormal =normalize(TransformViewToProjection(viewNormal.xyz))*pos.w;//将法线变换到NDC空间float4nearUpperRight =mul(unity_CameraInvProjection,float4(1,1,UNITY_NEAR_CLIP_VALUE,_ProjectionParams.y));//将近裁剪面右上角的位置的顶点变换到观察空间floataspect =abs(nearUpperRight.y /nearUpperRight.x);//求得屏幕宽高比ndcNormal.x *=aspect;pos.xy +=0.01*_OutlineWidth *ndcNormal.xy *v.vertColor.a;//顶点色a通道控制粗细o.pos =pos;o.vertColor =v.vertColor.rgb;returno;}fixed4frag(v2fi):SV_TARGET {returnfixed4(_OutLineColor *i.vertColor,0);//顶点色rgb通道控制描边颜色}

总结

在本节实现了一个不管摄像机距离,可以保持宽度不变的Back Facing描边方法。优化了Back Facing描边在不光滑物体出现的破边问题。实现了通过顶点色数据对描边进行调整的方法。在下一个章节中,将会讨论一些用于卡通渲染的光照计算的实现方法。


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部