一. 前言
对于卡通渲染而言,描边是一个非常重要的环节,非常影响游戏的品质,如果描边没做好,会大大降低游戏的美术水准。 看了下网上关于用UE4描边的案例,发现几乎都是基于屏幕空间后处理通过Slobe算法做描边的案例(也可以理解,毕竟在UE中写Shader这件事相对Unity还是有点复杂),这种描边方案不太好控制,所以一开始就毙掉了。 还有一种是修改引擎管线,加入自己的描边Pass,当然我也是这么做的,但是这种方案网络上的材质连线也都很粗糙,并没有详细说明引发的问题,以及解决方案,本身传承知识的习惯,经过了一段时间的攀爬,在此做个总结分享给大家,希望一起进步,撰写仓促,文章内容难免有错误纰漏之处,如若读者能不吝告知,则不胜感激。
二.问题提出
新增Pass的描边方法很常见原理也很简单,分两个Pass,第一个描边Pass中渲染背面,根据法线向外offset外扩,第二个Pass中正常渲染即可。这种方案对于软模型而言效果不错,而且高效。但是如果不做特殊处理,和在Unity引擎中做描边一样,也都会遇到以下两个问题:
1,随着相机距离拉远拉近,由于透视原因,描边的粗细会发生变化,而我们需要一种不论距离变化多大,描边始终是一个宽度的效果。
2,关于硬表面物体的描边,如下图的Cube这种硬表面模型而言,在法线断开处outline自然会出现断裂。还有游戏中比如武器,硬转折的发片等,这些物体由于法线顶点的不统一都会引发断裂问题。我们需要使其平滑且连续。
三.思路介绍与梳理
1,关于上文的问题1提出两种思路来解决。由于UE本身只提供了世界空间的偏移,所以:
(1). 想办法在世界空间中加入和摄像机相关的因子,具体实现见下文.
(2). 修改MobileBasePassVertexShader.usf顶点着色器文件,在其他空间做描边,如下图在Clip空间做偏移,外部传递进去WorldPositionOffset(在材质中需要变换到Clip空间)仅当作Clip空间的偏移参数。
2,关于上文的问题2也提出两种思路来解决(~~).
(1). 法线断裂其实是因为在三维软件中比如Max同一个顶点上光滑组不统一,每个顶点的法线不止一条,如下图,这样的话到了引擎中其实是会分开3个顶点的,每个顶点按照自己的法线方向往外扩,可不就出现断裂了嘛,所以方案也出来了,在美术制作模型的时候统一光滑组,即不给物体分光滑组,这样的话所有的物体由于顶点处的法线方向统一,所以也就不会发生断裂现象。但是这样的话没了光滑组,很多软边不太好处理,也只能退而求其次强行加倒角,在边需要柔化的地方倒角(maya中也可以加入循环边),用更多的面去卡线,这是一种简单而粗暴的做法。
(2),通过一种方法将平均后的法线写入FBX数据中,然后在引擎材质中访问修正后的数据当作外扩的法线使用. 这里需要考虑3个小问题,依次展开:
a,存在哪个数据通道。既然要保存数据到模型中去,要么保存到顶点色,要么切线中,要么UV中。 思来想去考虑到顶点色做描边的颜色和粗细,切线通道在头发各向异性高光中可能会用到,所以最后只剩下UV通道中,对于场景静态物体,第2套UV肯定不能占用,UE引擎需要第2套UV作为烘培需要用。对于角色动态物体,考虑到现在流行的次世代卡通着色,对于一些布料材质有可能需要细节法线纹理,而且会出现在某些特殊的位置,这个时候如果完全铺设在正常的第一套UV中,那么布料占据的UV可能会非常小,细节会损失,这时候就需要保存到第2套UV中, 所以最后我们选择把平均化后的法线数据保存到了模型的第3套UV中,第1套正常UV数据,第2套对于衣服细节纹理UV占用,第3套一般不会用到,所以正好适合放描边的数据,当然你也可以放到其他的4-8套的任意一套中。 想明白这一点后就可以开始计算法线平均值然后写入数据了。
b,写入哪个空间。因为我们在Shader中拿到的模型空间,已经是跟着骨骼运动后的模型空间了。 所以我们需要把数据存在切线空间里面或者直接储存在切线,然后转到模型,世界空间,就是骨骼运动后的数据了,也可以简单理解为运动数据存储在切线空间的变换矩阵里面。
c,使用什么工具写入。写入数据有几种方式,要么在三维软件Max或者Maya中计算顶点的平均法线写入,要么在其他可以操作模型的引擎中写入。 由于Unity原生读取Mesh的方便之处,再加上提供了FBX Export Package,使其变得相当方便,所以这里选用了Unity引擎。
方案选择:考虑到描边参数的可控性以及尽量少改动UE的原则的情况下,描边采用了不修改引擎,依然在世界空间下,不过把和相机因素考虑进去解决描边随相机变化粗细变化问题。为了得到最好的效果而且省去美术工作,选择修改法线数据,将平均后的法线数据保存在蒙皮网格的切线空间下的第3套UV中上
4.实践
1,通过修改引擎,加入自定义描边Pass,实现思路基本完全按照这个系列文章操作,没什么太多要说的。
白昼行姜暗夜摸王:尝试在UE4.22中实现罪恶装备Xrd的卡通渲染zhuanlan.zhihu.com2,不考虑模型描边断裂的情况,解决随相机距离远近变化的问题。最简单的如下图1所示,直接在世界空间挤出,肯定会引发描边的粗细会随相机的远近存在一个近大远小的问题。按照上述文章里的材质连线其实可以解决一部分问题,但是还是不是太好控制,于是自己改写了描边材质如下图2所示。这里获取模型和相机的距离对OutlineWidth进行补充,距离的越远补充的越大,越近补充的越小,而且加入了Power值控制强度而非仅仅线性,使其达到越远补充的效果越强的一种效果。同时将顶点色考虑进去,控制其描边颜色和粗细。
3,模型描边断裂的情况。打开Unity,导入FBX,编写获取平均法线,然后写入的功能逻辑。写入完以后,需要在Shader中读取获得并利用,为了确保正确这里也写一套进行验证。代码如下,当写入完成后可以看到下方效果对比图,Box的硬边变得连续了,所以证明数据已经成功写入。
(1),正常描边Pass
Pass
{
Cull Front
CGPROGRAM
#include "UnityCG.cginc"
#pragma vertex vert
#pragma fragment frag
fixed4 _OutlineCol;
float _OutlineFactor;
struct v2f
{
float4 pos : SV_POSITION;
};
v2f vert(appdata_full v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
float3 vnormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
//正常做法,视空间法线外扩,没考虑到相机的透视问题
//float2 offset = TransformViewToProjection(vnormal.xy);
//o.pos.xy += offset * _OutlineFactor / 1000;
//把clip.w考虑进去,解决相机拉远拉近导致描边殂谢改变的透视问题,以及屏幕宽高比描边不均衡问题(ScreenParam.xy).
float2 offset = normalize(clipNormal.xy) / _ScreenParams.xy * _OutlineWidth * o.pos.w * 2;
o.pos.xy += offset;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
return _OutlineCol;
}
ENDCG
}
(2),使用修正过的法线数据的描边Shader
v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
float3 worldBitangent = normalize(cross(worldNormal, worldTangent) * v.tangent.w);
float3x3 tangentTransform = float3x3(worldTangent, worldBitangent, worldNormal);
float3 fixedNormal = UnpackNormalRG(float3(v.uv2, 1.0));
fixedNormal = normalize(fixedNormal);
float3 worldSpaceFixedNormal = normalize(mul(fixedNormal, tangentTransform));
float3 localSpaceNormal = mul((float3x3)unity_WorldToObject, worldSpaceFixedNormal);
float3 viewSpaceNormal = mul((float3x3)UNITY_MATRIX_IT_MV, localSpaceNormal);
float2 ndcnormal = normalize(mul((float3x3)UNITY_MATRIX_P, viewSpaceNormal).xy) * o.pos.w;
o.pos.xy += ndcnormal / _ScreenParams.xy * _OutlineFactor * 2;
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
return _OutlineCol;
}
(3),保存模型法线数据的C#部分
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
public class PluginMeshTools
{
[MenuItem("MeshTools/模型平均法线写入UV2数据")]
public static void WirteAverageNormalToTangentToos()
{
MeshFilter[] meshFilters = Selection.activeGameObject.GetComponentsInChildren<MeshFilter>();
foreach (var meshFilter in meshFilters)
{
Mesh mesh = meshFilter.sharedMesh;
WirteAverageNormalToTangent(mesh);
}
SkinnedMeshRenderer[] skinMeshRenders = Selection.activeGameObject.GetComponentsInChildren<SkinnedMeshRenderer>();
foreach (var skinMeshRender in skinMeshRenders)
{
Mesh mesh = skinMeshRender.sharedMesh;
WirteAverageNormalToTangent(mesh);
}
}
private static void WirteAverageNormalToTangent(Mesh mesh)
{
var averageNormalHash = new Dictionary<Vector3, Vector3>();
for (var j = 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;
}
}
var averageNormals = new Vector3[mesh.vertexCount];
for (var j = 0; j < mesh.vertexCount; j++)
{
averageNormals[j] = averageNormalHash[mesh.vertices[j]];
}
var uv2 = new Vector2[mesh.vertexCount];
var oriTangents = mesh.tangents;
var oriNormals = mesh.normals;
var oriBitangent = Vector3.one;
for (var j = 0; j < mesh.vertexCount; j++)
{
oriBitangent = (Vector3.Cross(oriNormals[j], oriTangents[j]) * oriTangents[j].w).normalized;
//构建tbn矩阵, 默认tbn->切线到模型
var tbn = new Matrix4x4( oriTangents[j], oriBitangent, oriNormals[j], Vector4.zero);
//旋转矩阵的逆矩阵等于转置矩阵,所以转置tbn,获得其逆矩阵=>模型到切线空间
tbn = tbn.transpose;
var bakeNormal = tbn.MultiplyVector(averageNormals[j]).normalized;
//归一化后写入
uv2[j] = new Vector2(bakeNormal.x * 0.5f + 0.5f, bakeNormal.y * 0.5f + 0.5f);
}
//uv从0开始
mesh.SetUVs(2, uv2);
}
}
(4),效果对比,可以看出修正过的法线描边连续了
(5),通过PakageManager下载FBX Exporter包,下载完以后将修改后的Mesh数写入到FBX中并导出,特别的当导出蒙皮骨骼网格的时候要勾选带有AnimSkinned Mesh选项。
4,导入UE4中修改描边材质,取第3套UV数据(Texcoord[2]),观看效果,描边不再断裂了。
四.总结
上述通过借助Unity引擎来修改FBX数据的方式是一种新思路,并不算最优解,最优解是导入FBX后在UE引擎中直接修改SkeletalMesh的数据并保存(无奈太菜,改了几次都失败了,如果有大牛成功修改了,希望能够写一下文档让我等菜鸟观摩一下。。)。 同样也希望能有更多人将技术分享出来一起学习,借用隔壁yiyi大佬 @flashyiyi 的经典独白结语,他最近关于蓝色协议的分析也非常好,建议品读。
flashyiyi:蓝色协议技术分享解读zhuanlan.zhihu.com
reference:
---------------------------------------2019.6.15修改-----------------------------------------
----------------------------------
五 补充
经过yiyi大佬的指点,并参考了 @Oldside 的文章,修改了Fbx导入模块,主要是FbxSkeletalMeshImport.cpp文件,在这个文件中我们找到处理Fbx Mesh数据的方法 FillSkeletalMeshImportData,然后这个方法会调用FillSkelMeshImporterFromFbx这个方法,然后在这个方法里能看到具体如何将FBX数据转换成UE自己的数据,其实就是调用FbxSDK里的方法转成自己的形式而已。 所以在这之前我们只要将数据提前处理然后保存就可以了,效果其实是一样的, 如下
void UnFbx::FFbxImporter::StoreNormalsToVertColor(FbxMesh* mesh)
{
//获取layer
FbxLayer* layer0 = mesh->GetLayer(0);
//依次获取layer中的顶点色、2uv、法线、切线、副法线
FbxLayerElementVertexColor* VertColor = layer0->GetVertexColors();
FbxLayerElementUV* UV2 = mesh->GetLayer(1)->GetUVs();
FbxLayerElementNormal* VertNormal = layer0->GetNormals();
FbxLayerElementTangent* VertTangent = layer0->GetTangents();
FbxLayerElementBinormal* VertBinomral = layer0->GetBinormals();
//逐顶点遍历操作
for (int j = 0; j < mesh->GetPolygonVertexCount(); j++)
{
FbxArray<int> SameControlPointsIndex;
for (int k = 0; k < mesh->GetPolygonVertexCount(); k++)
{
//将重复的保存下来,代表是同一个顶点
if (mesh->GetPolygonVertices()[k] == mesh->GetPolygonVertices()[j])
{
SameControlPointsIndex.Add(k);
}
}
//去除重复数据
FbxArray<FbxVector4> Normals;
for (int x = 0; x < SameControlPointsIndex.Size(); x++)
{
FbxVector4 Normal = VertNormal->GetDirectArray()[SameControlPointsIndex[x]];
Normals.AddUnique(Normal);
}
//将所有不同方向的法线加在一起并归一化获得光滑法线
FbxVector4 SmoothNormal;
for (int n = 0; n < Normals.Size(); n++)
{
SmoothNormal += Normals[n];
}
SmoothNormal.Normalize();
//构建tbn矩阵
FbxVector4 Tangent = VertTangent->GetDirectArray()[j];
FbxVector4 Normal = VertNormal->GetDirectArray()[j];
FbxVector4 Bitangent = VertBinomral->GetDirectArray()[j];
//将法线从模型空间转为切线空间
FbxVector4 tmpVector;
tmpVector = SmoothNormal;
tmpVector[0] = Tangent.DotProduct(SmoothNormal);
tmpVector[1] = Bitangent.DotProduct(SmoothNormal);
tmpVector[2] = Normal.DotProduct(SmoothNormal);
tmpVector[3] = 0;
SmoothNormal = tmpVector;
//找到定点色对应的颜色索引
int VertColorIndex = VertColor->GetIndexArray()[j];
//将法线数值范围从-1~1处理为0~1后存入RGB通道中,A通道保持
//不变,因为其中存放着轮廓线大小信息
FbxColor Color;
Color.mRed = SmoothNormal[0] * 0.5f + 0.5f;
Color.mGreen = SmoothNormal[1] * 0.5f + 0.5f;
Color.mBlue = SmoothNormal[2] * 0.5f + 0.5f;
Color.mAlpha = VertColor->GetDirectArray()[VertColorIndex].mAlpha;
//将颜色写入顶点颜色layer中
VertColor->GetDirectArray().SetAt(VertColorIndex, Color);
//第二种方案:尝试将数据写入第2套UV,想办法把顶点色留出来用来设置描边的颜色,可惜失败了~~~
//具体原因不明,不知道UE在处理2UV的时候做了什么操作,但方法写出来供大家参考
if (UV2)
{
int UV2Index = UV2->GetIndexArray()[j];
FbxVector2 v2;
v2[0] = SmoothNormal[0];
v2[1] = SmoothNormal[1];
UV2->GetDirectArray().SetAt(UV2Index, v2);
}
}
}
修改过程中遇到了一点很诡异的问题,将平均化的法线保存到顶点色是可以的,保存到第2套UV中取出来的数据就坏了(具体原因不明,可能是在后续UE转化成自己数据的时候又进行了拆分?,所以最好在UE处理完FbxMesh数据后即FileSkelMeshImporterFromFbx方法后边修改,太长懒得改了~)。由于将平均化的法线保存到了顶点色中,所以描边颜色暂时能想到的方法是美术制作SkelMesh的第2套UV(也很简单,直接将UV1复制copy到UV2中就行),然后采样一张代表不同区域描边颜色的贴图。只不过从性能角度考虑,需要多采样一张代表不同区域描边颜色的贴图,这张图由于只是代表区域的描边颜色,纯色块,所以可以设置贴图大小非常小,应该也还好。 好了这样的化就实现了在UE里边自动化导入,自动化设置法线的流程了,比最初的方法更方便了一些。
完结
referrence:
Oldside:Unity硬表面模型描边断裂问题解决过程记录zhuanlan.zhihu.com