ue4 改变枢轴位置_在UE4引擎中做卡通描边的一点心得

4181f989447a9a31e481e46a70cbde14.png

一. 前言

对于卡通渲染而言,描边是一个非常重要的环节,非常影响游戏的品质,如果描边没做好,会大大降低游戏的美术水准。 看了下网上关于用UE4描边的案例,发现几乎都是基于屏幕空间后处理通过Slobe算法做描边的案例(也可以理解,毕竟在UE中写Shader这件事相对Unity还是有点复杂),这种描边方案不太好控制,所以一开始就毙掉了。 还有一种是修改引擎管线,加入自己的描边Pass,当然我也是这么做的,但是这种方案网络上的材质连线也都很粗糙,并没有详细说明引发的问题,以及解决方案,本身传承知识的习惯,经过了一段时间的攀爬,在此做个总结分享给大家,希望一起进步,撰写仓促,文章内容难免有错误纰漏之处,如若读者能不吝告知,则不胜感激。

二.问题提出

新增Pass的描边方法很常见原理也很简单,分两个Pass,第一个描边Pass中渲染背面,根据法线向外offset外扩,第二个Pass中正常渲染即可。这种方案对于软模型而言效果不错,而且高效。但是如果不做特殊处理,和在Unity引擎中做描边一样,也都会遇到以下两个问题:

1,随着相机距离拉远拉近,由于透视原因,描边的粗细会发生变化,而我们需要一种不论距离变化多大,描边始终是一个宽度的效果。

2,关于硬表面物体的描边,如下图的Cube这种硬表面模型而言,在法线断开处outline自然会出现断裂。还有游戏中比如武器,硬转折的发片等,这些物体由于法线顶点的不统一都会引发断裂问题。我们需要使其平滑且连续。

89c83ba8f6f03e52cd15a46cf69c6888.png

三.思路介绍与梳理

1,关于上文的问题1提出两种思路来解决。由于UE本身只提供了世界空间的偏移,所以:

8accbd3ee91bab2b0f62907206fa66fb.png

(1). 想办法在世界空间中加入和摄像机相关的因子,具体实现见下文.

(2). 修改MobileBasePassVertexShader.usf顶点着色器文件,在其他空间做描边,如下图在Clip空间做偏移,外部传递进去WorldPositionOffset(在材质中需要变换到Clip空间)仅当作Clip空间的偏移参数。

396be6da6f4bedcf274721030c7ed7d4.png

2,关于上文的问题2也提出两种思路来解决(~~).

(1). 法线断裂其实是因为在三维软件中比如Max同一个顶点上光滑组不统一,每个顶点的法线不止一条,如下图,这样的话到了引擎中其实是会分开3个顶点的,每个顶点按照自己的法线方向往外扩,可不就出现断裂了嘛,所以方案也出来了,在美术制作模型的时候统一光滑组,即不给物体分光滑组,这样的话所有的物体由于顶点处的法线方向统一,所以也就不会发生断裂现象。但是这样的话没了光滑组,很多软边不太好处理,也只能退而求其次强行加倒角,在边需要柔化的地方倒角(maya中也可以加入循环边),用更多的面去卡线,这是一种简单而粗暴的做法。

43bce9047f3122e1689d78d9e08d179a.png

(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.com
52d88853af209e64e37629967fcb3e07.png

2,不考虑模型描边断裂的情况,解决随相机距离远近变化的问题。最简单的如下图1所示,直接在世界空间挤出,肯定会引发描边的粗细会随相机的远近存在一个近大远小的问题。按照上述文章里的材质连线其实可以解决一部分问题,但是还是不是太好控制,于是自己改写了描边材质如下图2所示。这里获取模型和相机的距离对OutlineWidth进行补充,距离的越远补充的越大,越近补充的越小,而且加入了Power值控制强度而非仅仅线性,使其达到越远补充的效果越强的一种效果。同时将顶点色考虑进去,控制其描边颜色和粗细。

85d1435b27852f2a8e7ca93a901081a9.png
图1

247d21945ab79cd1ffa6b31f12a439cb.png
图2

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),效果对比,可以看出修正过的法线描边连续了

f79e4da15f128ba703ae404e34775f25.png

(5),通过PakageManager下载FBX Exporter包,下载完以后将修改后的Mesh数写入到FBX中并导出,特别的当导出蒙皮骨骼网格的时候要勾选带有AnimSkinned Mesh选项。

c3f848e22b6ffd8518802e240767421c.png

7504acc3c99e90397cc1b0fec130060f.png

4,导入UE4中修改描边材质,取第3套UV数据(Texcoord[2]),观看效果,描边不再断裂了。

d5ad61c3c02a3975cafa5922183b7e23.png

a545a27004ae72e5c4f4bd54118a4256.png
效果不明显

7f0b21bd2cc84284223916e484a35486.png

92c4843adc29075ebf19d3ccf005348b.png

四.总结

上述通过借助Unity引擎来修改FBX数据的方式是一种新思路,并不算最优解,最优解是导入FBX后在UE引擎中直接修改SkeletalMesh的数据并保存(无奈太菜,改了几次都失败了,如果有大牛成功修改了,希望能够写一下文档让我等菜鸟观摩一下。。)。 同样也希望能有更多人将技术分享出来一起学习,借用隔壁yiyi大佬 @flashyiyi 的经典独白结语,他最近关于蓝色协议的分析也非常好,建议品读。

flashyiyi:蓝色协议技术分享解读​zhuanlan.zhihu.com
9b8e25f410b914b54133fa54ce70598f.png

eeba7a60163d5b0391fd129cb11f7f55.png


reference:

喵刀Hime:【Job/Toon Shading Workflow】自动生成硬表面模型Outline Normal​zhuanlan.zhihu.com
0e390782ad043d9c6d7f497d88206d60.png
大胖:硬边外描边断边问题​zhuanlan.zhihu.com
d44e6af3f6e177bcca083cb03500cafd.png
https://www.videopoetics.com/tutorials/pixel-perfect-outline-shaders-unity/​www.videopoetics.com

---------------------------------------2019.6.15修改-----------------------------------------

----------------------------------

五 补充

经过yiyi大佬的指点,并参考了 @Oldside 的文章,修改了Fbx导入模块,主要是FbxSkeletalMeshImport.cpp文件,在这个文件中我们找到处理Fbx Mesh数据的方法 FillSkeletalMeshImportData,然后这个方法会调用FillSkelMeshImporterFromFbx这个方法,然后在这个方法里能看到具体如何将FBX数据转换成UE自己的数据,其实就是调用FbxSDK里的方法转成自己的形式而已。 所以在这之前我们只要将数据提前处理然后保存就可以了,效果其实是一样的, 如下

5a9bff7e1a8494a05e493e1c3708b75e.png

19a9bd9ecc38588d09220e1f63c6fe2a.png
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);
		}
	}
}

aa69c972bd49aa3bcdde5dc94954f9cd.png

修改过程中遇到了一点很诡异的问题,将平均化的法线保存到顶点色是可以的,保存到第2套UV中取出来的数据就坏了(具体原因不明,可能是在后续UE转化成自己数据的时候又进行了拆分?,所以最好在UE处理完FbxMesh数据后即FileSkelMeshImporterFromFbx方法后边修改,太长懒得改了~)。由于将平均化的法线保存到了顶点色中,所以描边颜色暂时能想到的方法是美术制作SkelMesh的第2套UV(也很简单,直接将UV1复制copy到UV2中就行),然后采样一张代表不同区域描边颜色的贴图。只不过从性能角度考虑,需要多采样一张代表不同区域描边颜色的贴图,这张图由于只是代表区域的描边颜色,纯色块,所以可以设置贴图大小非常小,应该也还好。 好了这样的化就实现了在UE里边自动化导入,自动化设置法线的流程了,比最初的方法更方便了一些。

完结

referrence:

Oldside:Unity硬表面模型描边断裂问题解决过程记录​zhuanlan.zhihu.com
f73b86c0719739a5719a274f8a70a4fb.png
  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值