lxl 发表于 2025-2-7 00:13:38

Unity TheHeretic Gawain Demo 异教徒Demo技术学习


《异教徒 Heretic》是Unity在2019年GDC大会上展示的技术Demo,部分资源于2020年中旬公开下载。
 
通常传统基于表情基或骨骼驱动的面部动画方案,虽然能够提供较为流畅的表现,但在精度和真实感上往往存在差距。
为了追求更高的真实还原度,《异教徒》Demo采用了4D捕捉技术,这项技术通过硬件设备精确捕捉每一帧的面部数据,
并使用中间软件对原始数据去噪后置入Unity,从而实现了更真实的还原。
 
官方Blog:
https://unity.com/blog/technology/making-of-the-heretic-digital-human-character-gawain
 
百度网盘缓存Demo下载地址(测试所使用版本Unity2021.3.26,HDRP 12):
链接: https://pan.baidu.com/s/1Mk3X8VZpeoQq-w5SfmsE2g 提取码: f75e
 
1.SkinDeformation

这部分主要处理4D设备捕捉到的表情动画,到Unity这个环节的数据应该已经过Wrap3D处理,
直接播放Demo场景里的Timeline即可单独预览:

 SkinDeformationClip是一个SO文件,存放烘焙好的动画信息,而SkinDeformationRenderer负责表情数据的最终渲染输出。
1.1 SkinDeformationRenderer

该脚本会读取blendInputs字段中的数据并拿来进行处理,该字段的赋值在SkinDeformationTimeline中:
var inputA = playable.GetInput(inputIndexA);var inputB = playable.GetInput(inputIndexB);var assetA = ((ScriptPlayable<SkinDeformationPlayable>)inputA).GetBehaviour().clip;var assetB = ((ScriptPlayable<SkinDeformationPlayable>)inputB).GetBehaviour().clip;//赋值处:target.SetBlendInput(0, assetA, (float)(inputA.GetTime() / assetA.Duration), inputWeightA);target.SetBlendInput(1, assetB, (float)(inputB.GetTime() / assetB.Duration), inputWeightB);
 
该脚本中的数据结构有标记Lo、Hi后缀字段,看上去似乎和低频高频数据有关,但实际上储存的是
当前帧和上一帧数据,以及插值数值。
for (int i = 0; i != subframeCount; i++){    subframes.frameIndexLo = i;    subframes.frameIndexHi = i + 1;    subframes.fractionLo = 0.0f;    subframes.fractionHi = 1.0f;}
还有一组Albedo的有关数据,但没有看到被使用:
private static readonly BlendInputShaderPropertyIDs[] BlendInputShaderProperties ={    new BlendInputShaderPropertyIDs()    {      _FrameAlbedoLo = Shader.PropertyToID("_BlendInput0_FrameAlbedoLo"),      _FrameAlbedoHi = Shader.PropertyToID("_BlendInput0_FrameAlbedoHi"),      _FrameFraction = Shader.PropertyToID("_BlendInput0_FrameFraction"),      _ClipWeight = Shader.PropertyToID("_BlendInput0_ClipWeight"),    },    new BlendInputShaderPropertyIDs()    {      _FrameAlbedoLo = Shader.PropertyToID("_BlendInput1_FrameAlbedoLo"),      _FrameAlbedoHi = Shader.PropertyToID("_BlendInput1_FrameAlbedoHi"),      _FrameFraction = Shader.PropertyToID("_BlendInput1_FrameFraction"),      _ClipWeight = Shader.PropertyToID("_BlendInput1_ClipWeight"),    },};
 
数据在导入时会通过MeshLaplacian进行降噪:
var laplacianResolve = (laplacianConstraintCount < frameVertexCount);if (laplacianResolve){#if SOLVE_FULL_LAPLACIAN    laplacianTransform = new MeshLaplacianTransform(weldedAdjacency, laplacianConstraintIndices);#else    laplacianTransform = new MeshLaplacianTransformROI(weldedAdjacency, laplacianROIIndices, 0);    {      for (int i = 0; i != denoiseIndices.Length; i++)            denoiseIndices = laplacianTransform.internalFromExternal];      for (int i = 0; i != transplantIndices.Length; i++)            transplantIndices = laplacianTransform.internalFromExternal];    }#endif    laplacianTransform.ComputeMeshLaplacian(meshLaplacianDenoised, meshBuffersReference);    laplacianTransform.ComputeMeshLaplacian(meshLaplacianReference, meshBuffersReference);}
 
在SkinDeformationClipEditor.cs中存放有ImportClip的逻辑。
当点击SO的Import按钮时触发。
 
1.2 NativeFrameStream

项目中使用了流式读取,从二进制文件中直接逐缓冲读取帧数据,
这也是为什么项目中的Job大多以指针作为参数的原因,如果用NativeArray涉及到拷贝操作(博主观点)。
另外使用非托管内存进行集合操作,还可以省去一些安全检查的性能开销,在顶点数极多的Mesh模型中,
这部分性能优势会被体现。
 
1.3 SkinDeformationFitting

该脚本主要通过最小二乘得到拟合表情的各个BlendShape权重。
并通过Accord.NET子集提供的接口,避免得到负数结果,这个在官方技术文章里有提到。
 最小二乘后的计算结果会存放在frames.fittedWeights中:
// remap weights to shape indicesfor (int j = 0; j != sharedJobData.numVariables; j++){    sharedJobData.frames.fittedWeights] = (float)x;}
在运行时存放在:
public class SkinDeformationClip : ScriptableObject{    public unsafe struct Frame    {      public float* deltaPositions;      public float* deltaNormals;      public float* fittedWeights;//<---      public Texture2D albedo;    }
最后会传入Renderer:
public class SkinDeformationRenderer : MeshInstanceBehaviour{        public float[] fittedWeights = new float[0];// used externally
 
在Renderer中混合代码如下:
for (int i = 0; i != fittedWeights.Length; i++)    smr.SetBlendShapeWeight(i, 100.0f * (fittedWeights * renderFittedWeightsScale));
 
补充:在最小二乘法求解过程中,如果当前矩阵与b矩阵之间的数值差异较大,那么解的结果通常会趋近于零。
相反,当前矩阵与b矩阵的数值较为接近时,求解结果的数值则相对较大。
这一点也符合最终混合权重系数时的逻辑。
 
1.4 Frame信息读取

在Renderer脚本中,会调用clip.GetFrame获得当前帧的信息。即Clip中的
这样一个unsafe结构:
public class SkinDeformationClip : ScriptableObject{    public unsafe struct Frame    {      public float* deltaPositions;      public float* deltaNormals;      public float* fittedWeights;      public Texture2D albedo;    }
 
读取时会从frameData取得数据,该字段为NativeFrameStream类型,内部为Unity的异步文件读取实现。
加载时,如果是编辑器下就从对应目录的bin文件加载否则从StreamingAssets加载:
void LoadFrameData(){#if UNITY_EDITOR    string filename = AssetDatabase.GetAssetPath(this) + "_frames.bin";#else    string filename = Application.streamingAssetsPath + frameDataStreamingAssetsPath;    Debug.Log("LoadFrameData " + filename + ")");#endif
2.SnappersHead

该脚本提供对控制器、BlendShape、Mask贴图强度信息的逻辑控制。
2.1 控制器


在场景中选中挂有SnappersHeadRenderer脚本的对象,即可在编辑器下预览控制器。
这里控制器只是GameObject,概念上的控制器。
它类似于DCC工具中的控制器导出的空对象,通过脚本获得数值,并在LateUpdate中输出到BlendShape从而起作用。
在层级面板位于Gawain_SnappersControllers/Controllers_Parent下,模板代码使用了136个控制器,
Gawain角色并没有使用所有控制器。
2.2 BlendShape & Mask贴图

SnappersHead脚本中主要是对之前SkinDeformation处理过的BlendShape进行钳制,
其代码应该是自动生成的:
public unsafe static void ResolveBlendShapes(float* a, float* b, float* c)      {            b[191] = max(0f, a[872] / 2.5f);            b[192] = max(0f, a[870] / 2.5f);            b[193] = max(0f, (0f - a[872]) / 2.5f);            b[294] = linstep(0f, 0.2f, max(0f, (0f - a[871]) / 2.5f));            b[295] = linstep(0.2f, 0.4f, max(0f, (0f - a[871]) / 2.5f));            b[296] = linstep(0.4f, 0.6f, max(0f, (0f - a[871]) / 2.5f));            b[297] = linstep(0.6f, 0.8f, max(0f, (0f - a[871]) / 2.5f));            b[298] = linstep(0.8f, 1f, max(0f, (0f - a[871]) / 2.5f));            b[129] = hermite(0f, 0f, 4f, -4f, max(0f, (0f - a[541]) / 2.5f));            b[130] = max(0f, a[542] / 2.5f);            b[127] = max(0f, (0f - a[542]) / 2.5f);            b[34] = max(0f, (0f - a[301]) / 2.5f);...
Mask贴图也是类似的方式,对Albedo、Normal、Cavity三种贴图进行后期权重混合与钳制,
最后将Mask混合强度信息传入Shader。
补充:皮肤Mask贴图与皮肤张力有关,在后续Enemies Demo中有基于这块内容加以升级。
3.SkinAttachment粘附工具

这一块主要是眉毛等物件在蒙皮网格上的粘附。
与UE Groom装配的做法类似,通过三角形重心坐标反求回拉伸后的网格位置。
(UE Groom官方讲解: https://www.bilibili.com/video/BV1k5411f7JD)
 
SkinAttachment组件表示每个粘附物件,SkinAttachmentTarget组件表示所有粘附物件的父容器,
模型顶点和边信息查找用到了KDTree,在项目内的KdTree3.cs脚本中,
三角形重心坐标相关函数在Barycentric.cs脚本中。
查找时,每个独立Mesh块被定义为island,在这个结构之下再去做查找,
例如眉毛的islands如下:

 
 
通过Editor代码,每个挂载有SkinAttachment组件的面板上会重绘一份Target Inspector GUI,方便编辑。
当点击编辑器下Attach按钮时,会调用到SkinAttachment的Attach函数:
public void Attach(bool storePositionRotation = true){    EnsureMeshInstance();    if (targetActive != null)      targetActive.RemoveSubject(this);    targetActive = target;    targetActive.AddSubject(this);    if (storePositionRotation)    {      attachedLocalPosition = transform.localPosition;      attachedLocalRotation = transform.localRotation;    }    attached = true;}
 
SkinAttachmentTarget组件会在编辑器下保持执行,因此在更新到LateUpdate时候会触发如下逻辑:
void LateUpdate(){    if (UpdateMeshBuffers())    {      ResolveSubjects();    }}
4.眼球

4.1 眼球结构

说一下几个关键性的结构:

[*]角膜(cornea) 最外边的结构,位于房水之外,覆盖房水部分,但不包括巩膜。它的主要作用是屈光,帮助光线聚焦到眼内
[*]房水(aqueoushumor)晶状体后的半球形水体,建模时眼球中心突起也是对应该结构,图形上经常要处理的眼球焦散、折射都是因为存在该结构的原因
[*]虹膜(Iris)关键性的结构,位于晶状体外,房水内。眼睛颜色不同也是因为该结构的色素不一样导致,虹膜起到收缩瞳孔的效果。虹膜在物理上呈微凸弯曲
[*]瞳孔(pupil)虹膜中心的黑点
[*]巩膜(sclera)眼白部分,通常需要一张带血丝的眼白贴图。巩膜自身会产生高光。
上述结构不包括睫毛、泪腺以及一些需要分离mesh的区域,此外这些结构都需要不同的反射率才能确保真实。
4.2 EyeRenderer

该Demo中的EyeRenderer实现了角膜、瞳孔、巩膜等效果的参数调节,后续这块内容被集成在HDRP的Eye Shader中,
并在Ememies Demo中得到再次升级。
4.3 眼球AO

使用ASG制作了眼球AO,ASG指AnisotropicSphericalGaussian各向异性球面高斯。
隐藏面部网格后,单独调节参数效果:

 
该技术类似球谐函数的其中一个波瓣,参数可自行微调。
将ASG代码单独提取测试效果:

原代码中给到了2个该技术的参考链接:
struct AnisotropicSphericalSuperGaussian{    // (Anisotropic) Higher-Order Gaussian Distribution aka (Anisotropic) Super-Gaussian Distribution extended to be evaluated across the unit sphere.    //    // Source for Super-Gaussian Distribution:    // https://en.wikipedia.org/wiki/Gaussian_function#Higher-order_Gaussian_or_super-Gaussian_function    //    // Source for Anisotropic Spherical Gaussian Distribution:    // http://www.jp.square-enix.com/info/library/pdf/Virtual%20Spherical%20Gaussian%20Lights%20for%20Real-Time%20Glossy%20Indirect%20Illumination%20(supplemental%20material).pdf    //    float amplitude;    float2 sharpness;    float power;    float3 mean;    float3 tangent;    float3 bitangent;};
5.Teeth&Jaw 颌骨

5.1 下颌骨位置修正

TeethJawDriver脚本提供了修改参数Jaw Forward,可单独对下颌位置进行微调,
隐藏了头部网格后非常明显(右侧参数为2):

另外该参数没有被动画驱动。
 
5.2 颌骨AO

颌骨AO(或者叫衰减更合理)通过外部围绕颌骨的6个点(随蒙皮绑定)代码计算得到。

 
通过球面多边形技术实现,在SphericalPolygon.hlsl中可查看:
void SphericalPolygon_CalcInteriorAngles(in float3 P, out float A){    const int LAST_VERT = (SPHERICALPOLYGON_NUM_VERTS - 1);    float3 N;    // calc plane normals    // where N = normal of incident plane    //   eg. N = cross(C, A);    //       N = cross(A, B);    {      N[0] = -normalize(cross(P, P[0]));      for (int i = 1; i != SPHERICALPOLYGON_NUM_VERTS; i++)      {            N = -normalize(cross(P1], P));      }    }    // calc interior angles    {      for (int i = 0; i != LAST_VERT; i++)      {            A = PI - sign(dot(N, P1])) * acos(clamp(dot(N, N1]), -1.0, 1.0));      }      A = PI - sign(dot(N, P[0])) * acos(clamp(dot(N, N[0]), -1.0, 1.0));    }}
 
6.杂项

6.1 ArrayUtils.ResizeCheckedIfLessThan

项目中许多数组都使用了这个方法,该方法可确保目标缓存数组的长度不小于来源数组。
一方面避免使用List,另一方面可很好的做到缓存,避免预分配。
该类还提供了一个ArrayUtils.CopyChecked接口,可直接执行分配+拷贝。
 
6.2 NativeArrayOptions.UninitializedMemory

项目中部分NativeList在初始化时使用了该参数:
// allocate buffersvar inputPositions = new NativeArray<Vector3>(numPositions, allocator, NativeArrayOptions.UninitializedMemory);var inputTexCoords = new NativeArray<Vector2>(numTexCoords, allocator, NativeArrayOptions.UninitializedMemory);var inputNormals = new NativeArray<Vector3>(numNormals, allocator, NativeArrayOptions.UninitializedMemory);var inputFaces = new NativeArray<InputFace>(numFaces, allocator, NativeArrayOptions.UninitializedMemory);
使用该参数后新创建的Native集合并不会初始化分配内存,但有助于提升性能。
 
6.3 头部骨架

官方博客提到,头部使用FACS (Facial Action Coding System) 骨架结构进行搭建。
6.4 VS Unreal MetaHuman

博主觉得适用场景不同,MetaHuman更多的是iPhone+Live link face这样的普适化方案,
而Unity Digit Human更多是4D捕捉起步的定制化方案,用的好用不好一部分在硬件。
此外也不能静态的去比较,例如MetaHuman推出了MetaHuman Animator而Unity Enemies Demo用到了zivaRT
当然,如果想最方便快速的完成入门级数字人制作,MetaHuman显然更胜一筹。
6.5 总结

在该Demo中,网格处理相对复杂,尤其是通过MeshAdjacency进行了顶点融合等操作。
这点在SkinAttachment粘附部分运用较多,时间原因不继续展开研究。
这些技术在Enemies Demo中得到了进一步升级。
项目中广泛使用了指针操作与Unity Job系统的结合,虽然不能确定仅仅使用指针就一定优于Unity.Mathematics,
但这一做法在性能优化上可能有所帮助。
 
可以预见,从传统的骨骼蒙皮技术,到更注重微表情、面部细小肌肉的解算模拟,再到利用机器学习实现的布料、褶皱。
角色渲染的提升方向至少已经有了明确的思路可循。在实时渲染领域,技术的不断进步为未来的渲染效果提供了新的可能性。
 
<hr> 参考&扩展阅读:
官方Blog Heretic Demo页:https://unity.com/blog/technology/making-of-the-heretic-digital-human-character-gawain
Making of The Heretic (Digital Dragons 2019):https://www.youtube.com/watch?v=5H9Jo2qjJXs
Megacity Unity Demo工程学习:https://www.cnblogs.com/hont/p/18337785
Unity FPSSample Demo研究:https://www.cnblogs.com/hont/p/18360437
Book of the Dead 死者之书Demo工程回顾与学习:https://www.cnblogs.com/hont/p/15815167.html
页: [1]
查看完整版本: Unity TheHeretic Gawain Demo 异教徒Demo技术学习