查看原文
其他

在虚幻引擎4.26命令行程序中处理网格模型

Ryan Shmidt 虚幻引擎 2022-05-24


原作者:Ryan Shmidt,翻译:曾嘉川,校对:周澄清

背景
本文作者Ryan Shmidt是著名的C#开源库Geometry3Sharp¹的作者,随着G3Sharp库的不断迭代改进,其功能变得愈发强大,然而C#的性能问题以及C#生态中缺乏稀疏线性解算器等关键数学库等因素成了其继续发展的瓶颈。于是,作者在2018年12月决定加入Epic Games,成为Epic Games的首席几何学家(Principal Geometry Scientist), 并着手将G3Sharp移植到C++。伴随着作者和虚幻引擎几何开发组的同仁们的辛劳付出,如今虚幻引擎C++版本的几何模型处理插件(Geometry Processing Plugin)的功能已经远远超过了C#版本的G3Sharp库。作者认为时机成熟,于是写下了一系列的技术博客来介绍如何在虚幻引擎中使用这个强大的几何模型处理库。

下文是系列教程中的第一篇,作者将引导读者完成一个示例,该示例生成下图所示的网格模型。在本篇中,作者将介绍如何在虚幻引擎中使用C++来完成之前在G3Sharp教程中所展示的大部分功能。为了避免在教程引入任何不必要的UE复杂性,作者将在极简的命令行程序中执行所有操作。但请记住,文中执行的所有操作在UE编辑器模式与运行时皆可用。

感谢曾嘉川对本文做了初稿翻译,以及Epic Games技术客户经理周澄清对本文内容进行校对。本篇译文对原文略作精简,可通过以下链接或点击文末“阅读原文”查看原始文章。
http://www.gradientspace.com/tutorials/2020/9/21/command-line-geometry-processing-with-unreal-engine


准备/ UE4设置
我们首先需要克服的一个小障碍是发布版的UE4无法生成命令行程序的可执行文件。因此,我们需要使用UE4源码版,一旦你加入Epic Games GitHub组织²,即可访问GitHub(单击说明链接——任何接受UE4许可协议的用户都可以免费获得)。本教程依赖UE4.26或更新的代码,因此建议你直接使用4.26分支³(本教程还支持现已正式发布的4.26 release分支)。

最简单的方法(在我看来)是使用“下载Zip”选项,该选项位于“代码”下拉按钮下(见下图)。下载并解压缩(这需要大约 1.7 GB 的磁盘空间)。之后,你需要在解压后的根目录中运行Setup.bat文件,该文件将下载另一个大约11GB的二进制文件,然后运行一个安装程序,安装后大约占据40GB空间。很不幸,没有更节约时间的办法了。不妨去喝杯咖啡吧!


本教程的代码可在GitHub上Gradientspace UnrealMeshProcessingTools的代码仓库中获得⁴,在UE4.26 Samples文件夹中名为CommandLineGeometryTest的子文件夹中。同样,你可以下载整个代码仓库的zip,也可以用git客户端同步。

假设你将UE代码仓库解压到名为“UnrealEngine-4.26”的文件夹中,则需要复制或移动示例代码文件夹UE4.26\CommandLineGeometryTestUnrealEngine-4.26/Engine/Source/Programs下,如下图所示。此文件夹包含UE使用的各种其他命令行工具和应用。你也许可以把它放在其他地方,但这是我测试它的地方,并且测试样本HoleyBunny.obj文件路径是根据这个目录结构进行的硬编码。


作为参考,我创建的这个示例项目是基于引擎中包含的BlankProgram命令行项目(你可以在上图的列表中看到它)。这是一个最小的Hello-World示例程序,也是你开发任何基于UE的命令行项目(例如用于单元测试等)的一个很好的起点。为了使其正确工作,我唯一必须做的修改,是添加对GeometryProcessing插件中几个模块的引用,在CommandLineGeometryTest.Build.cs文件:


  1. PrivateDependencyModuleNames.Add("GeometricObjects");

  2. PrivateDependencyModuleNames.Add("DynamicMesh");hNormals::QuickComputeVertexNormals(ImportMesh);


如果要在其他项目中使用这些模块,必须执行相同的步骤。请注意,引擎的许多模块并非直接就在命令行或“程序”目标类型中可用。例如,在BlankProgram中,UObject系统未初始化。所幸Geometry Processing插件模块只对引擎有最低限度的依赖,并且插件内没有定义任何UObject的派生类,因此这个限制对本教程不是什么问题。(如果你需要初始化各种引擎系统或模块,请参阅SlateViewer程序)

将文件放在正确的位置后,运行GenerateProjectFiles.bat文件。这将生成Visual Studio 2019 UE4 .sln文件。哦,顺便说一下,如果你在Windows上,你可能要安装Visual Studio 2019。如果你在Linux或OSX上,则上面提到的批处理文件有.command/.sh版本,本教程也应该能运行在这些平台上。(几何处理模块早已运行在了已发布的桌面、移动和主机平台游戏中!)


打开UE4 .sln,你将在解决方案资源管理器子窗口中找到一长串项目。查找我们的CommandLineGeometryTest项目,右键单击它,然后选择在上下文菜单中显示的“设置为启动项目”选项(如上图所示)。然后单击“开始调试”按钮或点击F5。这将花费一分钟左右生成程序,然后弹出一个命令行窗口,并打印出教程运行时的一些信息(应该仅需几秒钟)。

请注意,你不需要编译UE4的完整版本。由于我们正在构建一个简单的命令行应用,因此我们对大多数引擎或编辑器的模块没有任何依赖关系。一个完整的构建需要更长的时间——在我的24核ThreadRipper工作站上花费大约15分钟,而在我的4核笔记本上需要花费2个多小时。因此,请不要选择“构建解决方案”或“全部重建”,否则你可能会等很久。

教程文件


示例代码只包含几个文件,我们关心的所有代码都在CommandLineGeometrytest.cpp中。CommandLineGeometryTest.Build.cs和CommandLineGeometryTest.Target.cs是CommandLineGeometryTest工程的配置文件,而CommandLineGeometryTest.h 则是空的。

由于几何处理模块不直接支持任何文件I/O,因此DynamicMeshOBJReader.hDynamicMeshOBJWriter.h是读取/写入OBJ网格文件所必需的。OBJ Reader只是tinyobjloader库(https://github.com/tinyobjloader/tinyobjloader,源嵌入)的封装,用于构造一个FDynamicMesh3(我们将使用的网格类)。OBJ Writer是极简的,只包含最基础的功能。

CommandLineGeometryTest.cpp只包含#includes和main()函数,在不到150行代码里,我演示了法线计算、球体与立方体的生成、网格模型AABBTree生成和查询(射线求交)、网格体附着、基于快速绕组数的重采样、隐式形态操作、网格简化、各向同性的网格重建、孔填充和布尔运算(两次)。(是的,网格的布尔运算!!)

导入和属性
好了, 让我们开始分块阅读代码。第一个部分只是读取网格,打印一些信息,并计算法线(只是提醒,正如我上面提到的,FDynamicMeshOBJReader不是UE4的一部分,这是示例代码中包含的类)。请注意对FDynamicMesh3::ReverseOrientation()的调用。这是否有必要取决于你的输入文件,但通常情况下,UE4 使用左手坐标系,而大多数DCC工具都是右手系。这意味着,当导入UE4时,右手坐标系的网格将是“内外颠倒”的,也就是说网格上向外的曲面法线方向将错误地指向内侧。而如果我们在导入时调用ReverseOrientation(),在导出时再次反转,那么一切都会工作正常。


  1. // import an OBJ mesh. The path below is relative to the default path that Visual Studio will execute CommandLineGeometryTest.exe,

  2. // when using a normal UE4.26 auto-generated UE.sln file. If things change you might need to update this path

  3. FDynamicMesh3 ImportMesh;

  4. FDynamicMeshOBJReader::Read("..\\..\\Source\\Programs\\CommandLineGeometryTest\\HoleyBunny.obj", ImportMesh, true, true, true);

  5. // flip to UE orientation

  6. ImportMesh.ReverseOrientation();


  7. // print some mesh stats

  8. UE_LOG(LogBlankProgram, Display, TEXT("Mesh has %d vertices, %d triangles, %d edges"), ImportMesh.VertexCount(), ImportMesh.TriangleCount(), ImportMesh.EdgeCount());

  9. UE_LOG(LogBlankProgram, Display, TEXT("Mesh has %d normals"), ImportMesh.Attributes()->PrimaryNormals()->ElementCount());

  10. UE_LOG(LogBlankProgram, Display, TEXT("Mesh has %d UVs"), ImportMesh.Attributes()->PrimaryUV()->ElementCount());


  11. // compute per-vertex normals

  12. FMeshNormals::QuickComputeVertexNormals(ImportMesh);


日志记录调用没有什么特别的,我只是想有一个地方来展示对Attributes()的调用,它返回一个FDynamicMeshAttributeSet。FDynamicMesh3的设计与 Geometry3Sharp中的DMesh3非常相似,因此基本上也适用于FDynamicMesh3。然而,在UE的几何处理模块实现中额外增加了一个重要功能——支持任意属性集(Attribute Set),包括基于每个三角形索引的属性,这使得我们可以表示法线拆分和适当的UV island/atlas/overlay(具体取决于你喜欢的术语)。通常,几何处理中的网格编辑操作(如网格边缘拆分/翻转/折叠、FDynamicMeshEditor、简化器和网格重建器、编辑追踪等)可自动更新相应的属性集。

生成立方体网格
下一步是生成一个简单的立方体,我们将其附着在导入的兔子模型上数次。几何对象模块中的/Public/Generators/ subfolder中有各种各样的网格生成器FMinimalBoxMeshGenerator制作一个带12个三角面的立方体,稍后我们将使用FGridBoxMeshGenerator生成一个细分立方体。GeometryObjects模块还包括一个模板化的基础几何和矢量数学类型的库,通过typedef支持float单精度和double双精度。比如,FAxisAlignedBox3d是一个3D双精度轴对齐立方体,而FAxisAlignedBox2f是一个2D单精度矩形。对标准FVector/FBox等UE4类型的转换尽可能定义并实现(在安全的情况下支持隐式转换,否则通过强制转换)。但是,如果不是在声明时特别指定,默认几何处理库将使用双精度进行计算。


  1. // generate a small box mesh to append multiple times

  2. FAxisAlignedBox3d ImportBounds = ImportMesh.GetBounds();

  3. double ImportRadius = ImportBounds.DiagonalLength() * 0.5;

  4. FMinimalBoxMeshGenerator SmallBoxGen;

  5. SmallBoxGen.Box = FOrientedBox3d(FVector3d::Zero(), ImportRadius * 0.05 * FVector3d::One());

  6. FDynamicMesh3 SmallBoxMesh(&SmallBoxGen.Generate());



你会注意到,几乎每个类型都使用“F”的前缀。这是一个UE4约定,通常所有结构类型都使用F作为前缀。同样,这里的代码基本上遵循UE4编码标准(它包含的空格比我一般喜欢的要多很多,但就这样吧)。


生成AABBTree
这单行的FDynamicMeshAABBTree3的构造函数将自动生成AABBTree(可以通过可选参数禁用)。AABBTree结构相当快,通常没有理由使用其他可靠性不如它的东西(或者更加可怕地去使用线性搜索)。同样,复制FDynamicMesh3非常快,因为网格的存储不涉及每个元素指针,所以所有内容都位于块状数组中(请参阅TDynamicVector),可以进行内存块拷贝。最后,此代码创建一个FDynamicMeshEditor,它实现了许多常见的底层网格编辑操作。如果你需要做一些它不做的事情,通常最好尝试将问题分解为已经实现的操作,即使这会付出一些额外的成本,因为处理属性集更新是一件很“鸡毛”的事情。


  1. // create a bounding-box tree, then copy the imported mesh and make an Editor for it

  2. FDynamicMeshAABBTree3 ImportBVTree(&ImportMesh);

  3. FDynamicMesh3 AccumMesh(ImportMesh);

  4. FDynamicMeshEditor MeshEditor(&AccumMesh);


如果你要查看FDynamicMeshAABBTree3的代码,你会发现它只是TMeshAABBTree3<FDynamicMesh3>的一个typedef。这个AABBTree类只是一个基于不同网格类型的模板类,因为它只需要网格类型上几个函数而已。FTriangleMeshAdapterd结构可用于封装任何支持索引的网格模型到一个API中,使其与TMeshAABBTree3模板类协同工作,同理,TMeshQueries<T>模板类也支持多种类型的通用网格模型的查询操作。

AABBTree 查询
这个代码块内容很多,因为我们要做一些逻辑操作,但关键部分是调用FDynamicMeshAABBTree3::FindNearestTriangle()FDynamicMeshAABBTree3::FindNearestHitTriangle()。这是AABBTree类上最常见的两个查询。请注意,在这两种情况下,查询仅返回整数三角形ID/索引,然后TMeshQueries<T>用于执行和返回FDistPoint3Triangle3d/FIntrRay3Triangle3d对象。这些类也可以直接使用。它们返回点到三角形距离查询或射线与三角形的求交等各类信息。几何处理中的距离和求交查询通常用这样的形式实现,计算对象存储任何有用的中间信息,否则这些信息可能会被丢弃。在某些情况下,FDistXY/FIntrXY类具有静态函数,这些函数将执行更简单的计算。AABBTree类还具有::FindNearestPoint()辅助函数(射线求交没有类似的辅助函数)。


  1. // append the small box mesh a bunch of times, at random-ish locations, based on a Spherical Fibonacci distribution

  2. TSphericalFibonacci<double> PointGen(64);

  3. for (int32 k = 0; k < PointGen.Num(); ++k)

  4. {

  5.    // point on a bounding sphere

  6. FVector3d Point = (ImportRadius * PointGen.Point(k)) + ImportBounds.Center();


  7.    // compute the nearest point on the imported mesh

  8. double NearDistSqr;

  9. int32 NearestTriID = ImportBVTree.FindNearestTriangle(Point, NearDistSqr);

  10. if (ImportMesh.IsTriangle(NearestTriID) == false)

  11. continue;

  12. FDistPoint3Triangle3d DistQueryResult = TMeshQueries<FDynamicMesh3>::TriangleDistance(ImportMesh, NearestTriID, Point);


  13.    // compute the intersection between the imported mesh and a ray from the point to the mesh center

  14. FRay3d RayToCenter(Point, (ImportBounds.Center() - Point).Normalized() );

  15. int32 HitTriID = ImportBVTree.FindNearestHitTriangle(RayToCenter);

  16. if (HitTriID == FDynamicMesh3::InvalidID)

  17. continue;

  18. FIntrRay3Triangle3d HitQueryResult = TMeshQueries<FDynamicMesh3>::TriangleIntersection(ImportMesh, HitTriID, RayToCenter);


  19.    // pick the closer point

  20. bool bUseRayIntersection = (HitQueryResult.RayParameter < DistQueryResult.Get());

  21. FVector3d UsePoint = (bUseRayIntersection) ? RayToCenter.PointAt(HitQueryResult.RayParameter) : DistQueryResult.ClosestTrianglePoint;


  22. FVector3d TriBaryCoords = (bUseRayIntersection) ? HitQueryResult.TriangleBaryCoords : DistQueryResult.TriangleBaryCoords;

  23. FVector3d UseNormal = ImportMesh.GetTriBaryNormal(NearestTriID, TriBaryCoords.X, TriBaryCoords.Y, TriBaryCoords.Z);


  24.    // position/orientation to use to append the box

  25. FFrame3d TriFrame(UsePoint, UseNormal);


  26.    // append the box via the Editor

  27. FMeshIndexMappings TmpMappings;

  28. MeshEditor.AppendMesh(&SmallBoxMesh, TmpMappings,

  29. [TriFrame](int32 vid, const FVector3d& Vertex) { return TriFrame.FromFramePoint(Vertex); },

  30. [TriFrame](int32 vid, const FVector3d& Normal) { return TriFrame.FromFrameVector(Normal); });

  31. }


上面代码块中的最后一个调用通过FDynamicMeshEditor追加了我们在上面创建的SmallBoxMesh。两个lambda表达式将立方体网格的顶点和法线(以原点为中心)转换为与我们使用距离/射线求交得到的曲面位置和法线相对齐。这是通过FFrame3d完成的,它是在几何处理模块中大量使用的类。

TFrame3<T>是3D位置(称为.Origin)和方向(.Rotation),它表示为TQuaternion<T>,所以本质上就像一个标准的FTransform,没有任何缩放。但是,TFrame3类具有一个API,允许你将其视为一组位于空间中的3D正交轴。因此,X()、Y()和Z()函数返回三个轴。也有各种ToFrame()FromFrame()函数(FVector3<T> 必须区分点和矢量,但对于其他类型则存在不同函数重载)。ToFrame()将几何体映射到帧的局部坐标系。FromFrame()将帧“中”的点映射到其所在的“世界空间”(就该帧来说)中。因此,在上面的代码中,我们视立方体模型处在Frame坐标系中,并将其点和法线映射到网格表面上。 

最后一点,上面使用的FFrame3d(点,法线)构造函数将创建一个帧,“Z”与“法线”对齐,而X轴和Y轴方向并没有唯一确定下来。在许多情况下,你可能希望构造具有特定切面平面轴的框架。有一个构造函数需要输入X/Y/Z轴,但更多的情况是,你有一个法线和另一个方向,不一定是正交的法线。在这种情况下,你可以使用Z=NORMAL来构造,然后使用::ConstrainedAlignAxis()函数,通过围绕Z旋转,将其他两帧轴之一(如轴 X/0)与目标方向对齐。

使用Fast Mesh Winding Number “固化网格”
之前的几个Gradientspace教程使用Fast Mesh Winding Number可靠地计算网格上的点包含关系(即内部/外部测试)。Fast Mesh Winding Number的实现在GeometryObjects模块中提供,如TFastWindingTree<T>其中T是FDynamicMesh3或网格适配器。此数据结构构建在TMeshAABBTree<T>之上。在下面的代码中,我们构造其中一个,然后使用TImplicitSolidify<T>对象生成新的网格。TImplicitSolidify将TFastWindingTree产生的内值/外值解释为隐式曲面(请参阅这一教程⁶),并使用FMarchingCubes类为该隐式曲面生成三角形网格。


  1. // make a new AABBTree for the accumulated mesh-with-boxes

  2. FDynamicMeshAABBTree3 AccumMeshBVTree(&AccumMesh);

  3. // build a fast-winding-number evaluation data structure

  4. TFastWindingTree<FDynamicMesh3> FastWinding(&AccumMeshBVTree);


  5. // "solidify" the mesh by extracting an iso-surface of the fast-winding field, using marching cubes

  6. // (this all happens inside TImplicitSolidify)

  7. int32 TargetVoxelCount = 64;

  8. double ExtendBounds = 2.0;

  9. TImplicitSolidify<FDynamicMesh3> SolidifyCalc(&AccumMesh, &AccumMeshBVTree, &FastWinding);

  10. SolidifyCalc.SetCellSizeAndExtendBounds(AccumMeshBVTree.GetBoundingBox(), ExtendBounds, TargetVoxelCount);

  11. SolidifyCalc.WindingThreshold = 0.5;

  12. SolidifyCalc.SurfaceSearchSteps = 5;

  13. SolidifyCalc.bSolidAtBoundaries = true;

  14. SolidifyCalc.ExtendBounds = ExtendBounds;

  15. FDynamicMesh3 SolidMesh(&SolidifyCalc.Generate());

  16. // position the mesh to the right of the imported mesh

  17. MeshTransforms::Translate(SolidMesh, SolidMesh.GetBounds().Width() * FVector3d::UnitX());


TImplicitSolidify代码相对简单,我们可以很容易地在这里直接使用FMarchingCubes。但是,你会发现几何处理模块中有许多这样的“辅助”类,如TImplicitSolidify。这些类减少了执行常见网格处理操作所需的步骤数,从而将暴露某些参数的“处方”和/或用户界面变得更加方便。

网格形态操作和网格简化
现在,我们已经生成了一个由孔,兔子加上立方体组成的“固化”网格。下一步是对网格进行偏移。偏移可以直接在网格三角形上完成,但它也可以被视为形态操作,有时称为“扩张(Dilation )”(偏移量为负时为“ 侵蚀(Erosion) ”)。也有更有趣的形态操作,如“开口(Opening)”(先侵蚀再扩张)和“封口(Closure)”(先扩张再侵蚀),这对于填充小孔和空洞特别有用。这些通常很难直接在网格上实现,但很容易使用TImplicitMorphology<T>类中的implicit surface/level-set技术完成。与TImplicitSolidify类似,此类生成必要的数据结构,并使用FMarchingCubes生成输出网格。


  1. // offset the solidified mesh

  2. double OffsetDistance = ImportRadius * 0.1;

  3. TImplicitMorphology<FDynamicMesh3> ImplicitMorphology;

  4. ImplicitMorphology.MorphologyOp = TImplicitMorphology<FDynamicMesh3>::EMorphologyOp::Dilate;

  5. ImplicitMorphology.Source = &SolidMesh;

  6. FDynamicMeshAABBTree3 SolidSpatial(&SolidMesh);

  7. ImplicitMorphology.SourceSpatial = &SolidSpatial;

  8. ImplicitMorphology.SetCellSizesAndDistance(SolidMesh.GetCachedBounds(), OffsetDistance, 64, 64);

  9. FDynamicMesh3 OffsetSolidMesh(&ImplicitMorphology.Generate());


  10. // simplify the offset mesh

  11. FDynamicMesh3 SimplifiedSolidMesh(OffsetSolidMesh);

  12. FQEMSimplification Simplifier(&SimplifiedSolidMesh);

  13. Simplifier.SimplifyToTriangleCount(5000);

  14. // position to the right

  15. MeshTransforms::Translate(SimplifiedSolidMesh, SimplifiedSolidMesh.GetBounds().Width() * FVector3d::UnitX());


操作后立方体网格通常非常密集,因此在之后简化网格是一种常见的模式。这可以使用FQEMSimplification在几行代码内完成,该类支持指定各种简化条件,当然除此以外还有其他网格简化器的实现,尤其是FAttrMeshSimplification支持法线和UV属性的叠加。 

网格布尔运算(!!!)
这是你一直在期待的时刻——网格布尔运算!在这里,我们首先使用FSphereGeneratorFGridBoxGenerator生成两个网格模型,然后使用FMeshBoolean得到球体和立方体的差集。FMeshBoolean构造函数对每个输入网格进行变换,但仅支持两个输入网格(它不是N路布尔)。如果要使用多个输入网格,必须使用重复的FMeshBoolean操作,但如果输入不相交,则首先使用FDynamicMeshEditor::AppendMesh()将它们组合起来进行运算会更加高效。


  1. // generate a sphere mesh

  2. FSphereGenerator SphereGen;

  3. SphereGen.Radius = ImportMesh.GetBounds().MaxDim() * 0.6;

  4. SphereGen.NumPhi = SphereGen.NumTheta = 10;

  5. SphereGen.bPolygroupPerQuad = true;

  6. SphereGen.Generate();

  7. FDynamicMesh3 SphereMesh(&SphereGen);


  8. // generate a box mesh

  9. FGridBoxMeshGenerator BoxGen;

  10. BoxGen.Box = FOrientedBox3d(FVector3d::Zero(), SphereGen.Radius * FVector3d::One());

  11. BoxGen.EdgeVertices = FIndex3i(4, 5, 6);

  12. BoxGen.bPolygroupPerQuad = false;

  13. BoxGen.Generate();

  14. FDynamicMesh3 BoxMesh(&BoxGen);


  15. // subtract the box from the sphere (the box is transformed within the FMeshBoolean)

  16. FDynamicMesh3 BooleanResult;

  17. FMeshBoolean DifferenceOp(

  18. &SphereMesh, FTransform3d::Identity(),

  19. &BoxMesh, FTransform3d(FQuaterniond(FVector3d::UnitY(), 45.0, true), SphereGen.Radius*FVector3d(1,-1,1)),

  20. &BooleanResult, FMeshBoolean::EBooleanOp::Difference);

  21. if (DifferenceOp.Compute() == false)

  22. {

  23. UE_LOG(LogGeometryTest, Display, TEXT("Boolean Failed!"));

  24. }

  25. FAxisAlignedBox3d BooleanBBox = BooleanResult.GetBounds();

  26. MeshTransforms::Translate(BooleanResult,

  27. (SimplifiedSolidMesh.GetBounds().Max.X + 0.6*BooleanBBox.Width())* FVector3d::UnitX() + 0.5*BooleanBBox.Height()*FVector3d::UnitZ());


请注意,网格布尔运算不是100%可靠。下面我将展示如何(尝试)修复故障。
网格重建
接下来,我们将对布尔结果应用一个各向同性三角形重建的操作。如果你计划进行进一步的网格处理(如变形/平滑/等),这是一个标准步骤,因为网格布尔运算输出的三角形大小/密度的变化范围很大(这约束了网格移动方式)和可能导致各种问题的Silver Triangles。标准做法是使用FRemesher并在整个网格模型上运行一系列的操作。下面我使用了FQueueRemesher,它产生几乎相同的结果,但它不用每次都处理全部网格模型,而是使用一个“激活队列”不断追踪需要处理的网格。它的速度通常会明显更快(尤其是在大型网格上)。


  1. // make a copy of the boolean mesh, and apply Remeshing

  2. FDynamicMesh3 RemeshBoolMesh(BooleanResult);

  3. RemeshBoolMesh.DiscardAttributes();

  4. FQueueRemesher Remesher(&RemeshBoolMesh);

  5. Remesher.SetTargetEdgeLength(ImportRadius * 0.05);

  6. Remesher.SmoothSpeedT = 0.5;

  7. Remesher.FastestRemesh();

  8. MeshTransforms::Translate(RemeshBoolMesh, 1.1*RemeshBoolMesh.GetBounds().Width() * FVector3d::UnitX());


我在之前的G3Sharp教程中介绍了各向同性网格重建的基本知识。该教程基本上直接适用于UE4的几何模型处理模块的实现,包括类型和字段名称(不要忘记添加F)。但是,UE4版本的能力要更胜一筹,例如FQueueRemesher的速度要快得多,而且还有FSubRegionRemesher可以重建网格的一部分。

关于上面代码有两个注意点。首先,我没有使用投影目标(Projection Target),因此球体与立方体的差集上清晰的边缘将被平滑。设置投影目标只需要几行,搜索引擎代码中FMeshProjectionTarget的任何用法。其次,在制作上面的网格副本后,我做的第一件事是调用RemeshBoolMesh. DiscardAttributes()。此调用从网格中删除所有三角形上叠加的属性层,特别是UV和法线层。Remeshers确实支持对每个三角形上叠加的属性进行重建,但它更为复杂,因为这些属性具有必须保留的其他拓扑约束。函数FMeshConstraintsUtil::ConstrainAllBoundariesAndSeams()可用于或多或少自动化设置这一切,但即使只是调用这个函数都有些小复杂,所以我想我会在未来的教程中来探讨这一话题(如果你想看一个例子,可以搜索FRemeshMeshOp)。 

孔填充和布尔故障处理


最后,我们将计算平滑后的球体与立方体的差集与“固化”后的带立方体的兔子模型之间的布尔交集。这再次只需构建一个FMeshBoolean对象然后调用一次Compute()。但是,在这种情况下,输入对象相当复杂,并且网格布尔运算的输出很可能未完全封闭。

为什么?嗯,网格布尔运算是“出了名”的不可靠。如果你使用了像Maya或3ds Max这样的工具很多年了,你就会记得网格布尔操作过去非常不可靠,之后突然在某个版本,它们变得靠谱了一些。这主要是由于这些软件更改了他们使用的第三方网格布尔库。实际上,没有多少选择。CGAL库⁷具有相当强大的多面体布尔运算,但它非常非常慢,并且其许可无法在商业游戏引擎中对其重新分发。Blender使用Carve,是相当不错的,但它是GPL许可。Cork⁹是不错的,但不是处于积极维护中。LibIGL¹⁰中的网格布尔使用最近引入的网格排列¹¹技术,基本上是当前最先进的,但在大型网格上也有些缓慢,并且依赖于一些CGAL代码。如果你深挖,你会发现许多商业工具或开源库中可用的布尔运算基本都是来自这四个工具之一。

第三方网格布尔运算库的另一个复杂是它们通常不支持任意的复杂网格属性,如我上面提到的逐三角形索引的叠加。因此,在UE4.26中,我们自己编写了针对FDynamicMesh3的实现的一个好处是,我们可以利用一些现代三角形网格处理技术。例如,当以前的网格布尔运算器灾难性地失败时,即部分输出消失时,通常是因为它们无法从几何上判断什么是“内部”和“外部”。现在,我们有Fast Mesh Winding Number,这基本上是一个已解决的问题,因此,UE4的FMeshBoolean的错误结果往往是可恢复的。例如,在上方的图像中,球体网格上有一个巨大的孔,通常是会导致网格布尔运算失败的输入,但只要相交线能够被良好定义,FMeshBoolean通常就会起作用。即使它(第二排的图像)出错,布尔运算变得不再有意义,但至少其失败不是灾难性的,我们只是在一个原来有洞的地方得到了一个洞。

综上,如果你的FMeshBoolean::Compute()返回false,意味着结果可能有一些洞,你可以填补他们。FMeshBoundaryLoops对象将提取一组表示网格的开放边界循环的FEdgeLoop对象(另一个超级难题...),然后FMinimalholeFiller将填充它们(我们还有FPlanarHoleFillerFSmoothHoleFiller,但它们可能不适用于当前情况)。请注意,大多数“孔”是沿两个对象之间的交集曲线的零面积区域的裂纹,因此“塌陷”掉这些退化的三角形会很有益处(库不会自动执行)。


  1. // subtract the remeshed sphere from the offset-solidified-cubesbunny

  2. FDynamicMesh3 FinalBooleanResult;

  3. FMeshBoolean FinalDifferenceOp(

  4. &SimplifiedSolidMesh, FTransform3d(-SimplifiedSolidMesh.GetBounds().Center()),

  5. &RemeshBoolMesh, FTransform3d( (-RemeshBoolMesh.GetBounds().Center()) + 0.5*ImportRadius*FVector3d(0.0,0,0) ),

  6. &FinalBooleanResult, FMeshBoolean::EBooleanOp::Intersect);

  7. FinalDifferenceOp.Compute();


  8. // The boolean probably has some small cracks around the border, find them and fill them

  9. FMeshBoundaryLoops LoopsCalc(&FinalBooleanResult);

  10. UE_LOG(LogGeometryTest, Display, TEXT("Final Boolean Mesh has %d holes"), LoopsCalc.GetLoopCount());

  11. for (const FEdgeLoop& Loop : LoopsCalc.Loops)

  12. {

  13. FMinimalHoleFiller Filler(&FinalBooleanResult, Loop);

  14. Filler.Fill();

  15. }

  16. FAxisAlignedBox3d FinalBooleanBBox = FinalBooleanResult.GetBounds();

  17. MeshTransforms::Translate(FinalBooleanResult,

  18. (RemeshBoolMesh.GetBounds().Max.X + 0.6*FinalBooleanBBox.Width())*FVector3d::UnitX() + 0.5*FinalBooleanBBox.Height()*FVector3d::UnitZ() );


几何模型处理库
上面的示例演示了如何使用GeometryProcessing插件中的部分数据结构和算法。除此以外插件还有许许多多其他算法,甚至那些上面演示的算法还包含许多其他的选项和功能。你将在\Engine\Plugins\Experimental\GeometryProcessing\找到所有代码,有四个模块:

  • GeometryObjects:模板化的矢量数学和几何类型、距离和求交计算、空间数据结构(如AABBTrees/Octrees/Grids/哈希表)和网格(grid)、网格(mesh)生成器、2D 图形和多边形、通用网格算法(不特定于 FDynamicMesh3)和隐式曲面

  • DynamicMesh:FDynamicMesh3及相关数据结构、布尔运算和切割、诸如拉伸和偏移的编辑操作,变形、采样、参数化、烘焙、形状拟合等。几乎所有的网格处理代码都在这里

  • GeometryAlgorithms:包含计算几何算法实现,如德劳奈三角化(Delaunay Triangulation)、凸包、线段排列等。此模块在某些地方使用第三方Boost授权许可的GTEngine库(包含在/Private/ThirdParty/)以及Shewchuck’s Exact Predicates¹²

  • MeshConversion:用于在网格类型之间转换的辅助类。当前主要用于转换为/从FMeshDescription,虚幻引擎中使用的另一种主要的网格格式。


我鼓励你去探索。如果你发现一个类或函数看起来有趣,但不确定如何使用它,你几乎肯定会在引擎代码库的其他位置找到一些用法。虚幻引擎的一大妙处是,你有一切代码,包括编辑器。因此,如果你在使用建模工具编辑器模式时看到一些有趣的东西,并且想要知道如何自己操作,你可以在MeshModelingToolset插件中找到代码。这是基于GeometryProcessing构建的,并实现了几乎所有编辑器中的的交互式工具。

其中许多工具(尤其是那些“设置选项和流程”以及需要较少“鼠标操作”的工具)被拆分为工具级代码(在MeshModelingTools模块中)和我们称之为“操作算子(operator)”的工具(在ModelingOperators模块中)。算子基本上是一个带有高阶参数的执行相对复杂的多步骤网格处理的“配方”。因此,诸如FBooleanMeshesOp算子最终在两个输入FDynamicMesh3上运行一个FMeshBoolean,但如果bAttemptFixHoles设置为true,它会自动执行上面的孔填充修复步骤。算子可以安全地在后台线程上运行,并可以结合FProgressCancel对象使用,以便于在长时间的计算过程中随时安全的中止计算并退出。

创建你自己的编辑器中几何处理工具
本教程已演示如何在命令行工具中使用几何处理模块。但正如我上面提到的,这个插件可以用来在编辑器中实现相同的网格处理。我以前在UE 4.24中使用LibIGL制作交互式网格平滑工具的教程¹³已经演示了如何做到这一点!!那个教程最终将问题归结为实现一个MakeMeshProcessingFunction()函数,该函数返回了TUniqueFunction<void(FDynamicMesh3&)>,即处理输入FDynamicMesh3的 lambda。在那个教程中,我们调用LibIGL代码,以便我们转换为/从 LibIGL的网格格式。但现在通过本篇教程你应该知道,你已不再需要LibIGL,并直接使用UE的GeometryProcessing模块编辑输入的网格! 

我已经更新了UE 4.26的教程,需要在此特别说明其中的一个必要改变。在UE 4.26中,我们添加了一个新的“基本工具类” UBaseGeometryProcessingTool,这使得本教程的代码变得简洁许多。

正如我所提到的,GeometryProcessing插件是由多个运行时模块组成,所以没有什么可以阻止你在游戏或打包应用中使用它们。我将在未来的教程探索这一话题 —— 敬请期待!


文中提及的相关链接
[1] Geometry3Sharp
https://github.com/gradientspace/geometry3Sharp

[2] GitHub
https://www.unrealengine.com/zh-CN/ue4-on-github?lang=zh-CN

[3] UE4.26 分支
https://github.com/EpicGames/UnrealEngine/tree/4.26

[4] 本教程代码获取途径
https://github.com/gradientspace/UnrealMeshProcessingTools/tree/master/UE4.26/CommandLineGeometryTest

[5] 之前的Gradientspace教程
http://www.gradientspace.com/tutorials/2018/9/14/point-set-fast-winding
http://www.gradientspace.com/tutorials/2017/12/14/3d-bitmaps-and-minecraft-meshes

[6] 教程参阅
http://www.gradientspace.com/tutorials/2018/2/20/implicit-surface-modeling

[7] CGAL库
https://doc.cgal.org/latest/Nef_3/index.html

[8] Carve
https://code.google.com/archive/p/carve/

[9] Cork
https://github.com/gilbo/cork

[10] LibIGL
https://libigl.github.io/tutorial/#boolean-operations-on-meshes

[11] 网格排列
http://www.cs.columbia.edu/cg/mesh-arrangements/

[12] Shewchuck’s Exact Predicates
https://www.cs.cmu.edu/~quake/robust.html

[13] 在UE 4.24中使用LibIGL制作交互式网格平滑工具的教程
http://www.gradientspace.com/tutorials/2020/1/2/libigl-in-unreal-engine

近期焦点
虚幻引擎4.26正式发布!
精选免费商城内容 - 2021年1月
虚幻引擎次世代游戏集锦
汽车领域指南:用虚幻引擎构建汽车平台
Weta Digital实时毛发短片《Meerkat》幕后揭秘 立即获取示例项目!

扫描下方二维码,关注后点击菜单栏按钮“更多内容”并选择“联系我们”获得更多虚幻引擎的授权合作方式和技术支持;选择“招聘”,即可了解最新招聘信息。

长按屏幕选择“识别二维码”关注虚幻引擎
“虚幻引擎”微信公众账号是Epic Games旗下Unreal Engine的中文官方微信频道,在这里我们与大家一起分享关于虚幻引擎的开发经验与最新活动。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存