这篇文章探讨了整个细分和变形过程中出现的各种问题的解决方案。然后,它将其扩展为完整的管道,用于变形和操纵 3D 网格,并计算着色和位移的精确法线。
1、简单细分
在 3D 渲染中,所有网格都由三角形组成。当模型从 Blender 或任何其他 3D 建模应用程序中导出时,该应用程序会将该网格分解为一组三角形,3D 引擎渲染后会将其转换为一个或多个对 GPU 的绘制调用。
对于我的用例,我希望能够细分任何 3D 网格,并能够使用导出后的 3D 数据来执行此操作。这个想法是将一组三角形变成更多、更小的三角形。一种简单的方法是取每个面积大于某个阈值的三角形,然后通过分割其最长边将其切成两半。
这是经过 2 轮细分后在 Blender 中的样子:
是的,这真的没有什么突破性。你可以通过其他方式进行细分,例如在中心添加一个顶点,然后通过向现有顶点绘制新边将其分成三个,或者你想要的任何方式。这只是将三角形变成更小的三角形。
最好的部分是它有效!我实现了一些简单的代码来执行此操作,将其应用于一些简单的测试网格,发现它们看起来……完全一样。
这正是在这种情况下应该发生的事情,因为我们实际上并没有改变几何体的整体形状 - 只是将其分成更小的部分。
2、变形 + 位移
现在我已经分割了网格,我想我应该测试一些基本的变形。
变形(deformation)一词实际上并不意味着对网格拓扑进行了什么样的更改。我说它的主要意思是网格顶点的位置正在以某种方式改变,从而改变网格的结构。
移动它们时,重要的是避免破坏网格的拓扑结构,因为面相互夹在一起或向后扭曲(因为渲染时只有面的正面是可见的)。由于 3D 网格可以呈现任意数量的复杂形状,因此通常不清楚以特定方式移动特定顶点会对整个网格产生什么影响。
执行此操作的一个好方法是沿其法线移动顶点。如果你不熟悉,我很快会更多地谈论法线,但概念是顶点根据其在网格表面上的局部角度向内或向外移动。
对于本文的其余部分,我将把沿其法线移动顶点称为位移(displacement)。
3、位移贴图
在 3D 图形中,位移贴图是一种通过基于从某些纹理(位移贴图)读取的值修改顶点着色器中顶点的位置来为网格添加更多几何细节的方法。
该过程本身也非常简单。每个顶点沿着其法线向量向前或向后移动,具体取决于该顶点的位移贴图中的值,使用专用 UV 贴图查找。
以下是 Three.JS 用于实现它的完整着色器代码,供参考:
transformed += normalize(objectNormal) * (texture2D(displacementMap, vDisplacementMapUv).x * displacementScale + displacementBias);
不过,这种简单性是有代价的。
为了使位移贴图看起来不错,通常需要另一组贴图,如法线贴图或凹凸贴图。如果缺少这些贴图,几何图形的变化将不会与阴影对齐,镜面高光和阴影等东西会看起来不协调甚至完全中断。
4、简单的细分位移结果
我没有使用位移贴图,而是对顶点着色器进行了小幅调整,只对网格中的每个顶点应用一个向外的恒定位移。这更像是一次健全性检查,以确保细分确实像我预期的那样工作。
当我在 Three.JS 场景中设置好所有内容时,我看到了以下内容:
(左)原始模型 (右)位移模型
如你所见,它绝对没有像我预期的那样工作。要理解为什么会出现这种情况,需要对 3D 图形的工作方式有一定的了解。
5、法线
对于本文的其余部分(以及一般的 3D 图形)来说,一个极其重要的概念是法线。法线(normal)或法线向量是垂直于其他物体的角度。对于三角形或平面等物体,它们的法线从其正面“射出”。
顺便说一句,我很好奇这些东西是如何被命名为“法线”的。
事实证明,2000 多年来,人们一直将直角称为“法线”。使用法线来表示常规或标准似乎要年轻得多。很奇怪。
在 3D 图形中,法线被分配给顶点。每个顶点都会获得一个法线,它是所有共享它的面的平均值。 Blender 是一款功能极其丰富的应用程序,它内置了对网格上不同类型法线的可视化支持。以下是之前渲染的立方体的一部分:
浅蓝色线是面法线,深蓝色线是通过平均附加面法线产生的顶点法线,洋红色线称为“分割”或“循环”法线。我稍后会详细讨论这些。
6、平滑 + 平面着色
无论如何,法线对 3D 渲染如此重要的主要原因之一是它们对着色的影响。用于模拟光与表面相互作用方式的大多数方程式都依赖于了解被照亮的表面与光源之间的角度以及表面与相机之间的角度。法线对于实现这一点至关重要。
法线还提供了在平滑(smooth)和平坦(flat)着色模型之间切换的能力。使用平滑着色,根据每个片段与其每个三角形顶点的距离对法线进行插值。这使得每个片段的计算法线在整个网格表面上连续。
以下是平滑着色(左)和平坦着色(右)的比较:
对于平坦着色,每个片段的法线都设置为面的法线。这意味着需要为每个面创建唯一的顶点 - 即使这些顶点位于完全相同的位置 - 因为它们需要分配唯一的法线。
我之前测试位移的立方体是从 Blender 导出的,带有平坦着色。这绝对适合该网格;它的所有面都彼此成非常锐利的角度,如果试图平滑这些角度,会让它看起来很奇怪。然而,这正是破坏位移的原因。
为了便于平坦着色,Blender 需要在立方体的每个角上复制 3 个顶点。它们具有完全相同的位置,但它们的法线不同,以匹配它们所使用的三角形。这就是之前 Blender 屏幕截图中那些洋红色线条所代表的。它们显示了在应用当前着色模型的情况下导出的网格中将存在的不同法线。
如果使用这些洋红色分割法线来位移顶点,则效果是同一起点的顶点将移动到不同的终点。
要使位移起作用,必须计算新的法线,以便将同一位置的顶点移动到同一目的地,从而使网格保持粘合在一起。
7、LinkedMesh数据结构
此时,我意识到我需要以比我迄今为止使用的“只是一堆三角形”系统更复杂的方式表示网格。
在阅读过去遇到的各种帖子和库时,我听到很多关于半边(halfedge)数据结构的提及。这是一种表示 3D 网格的方法,通常由“严肃的”几何库和工具使用。它将网格表示为图形,其组成部分(顶点、边和面)都指向彼此。 “半边”用于指代围绕面的两个顶点之间的边的单一方向。
我四处寻找,看看能否在 Rust 中找到一个记录良好且维护良好的半边库,但我没有找到任何有吸引力的东西。
因此,我决定实现自己的
LinkedMesh
数据结构来表示网格。
它类似于半边数据结构,并且在许多方面都简化了半边数据结构,但有一些其他区别:
- 没有独立的顶点或边。所有顶点都必须是边的一部分,所有边都必须是面的一部分。
- 所有面都是三角形;没有 N 边。
- 边上没有隐式方向。相反,面只保存对组成它们的 3 个顶点的有序引用列表。
就像半边数据结构一样,它维护不同实体之间的双向链接,如下所示:
拥有这样的表示使得许多与操作网格相关的事情变得更容易和/或更高效。
例如,获取与其他面接壤的所有面的列表变得非常容易:只需遍历其所有 3 条边的面列表即可。即将出现的多个事情都对这个数据结构至关重要。
8、LinkedMesh实现细节
与其余代码一样,我在 Rust 中实现了 LinkedMesh
。尽管人们经常谈论由于借用检查器的工作方式,在 Rust 中构建链接数据结构很困难,但 slotmap crate 使这几乎不成问题。
你不是将指向其他实体的指针存储在其中,而是将特殊标记的索引存储在专用堆中 - 就像竞技场分配一样。这会增加一点开销,但它还提供了额外的检查,可以防止使用对具有相同索引的实体的陈旧引用等错误。
这是因为 SlotMap 键包含一个版本,该版本在每次更新索引时都会递增,因此插入到重复使用的索引中的新实体将具有与以前存在的旧实体不同的键。仅这一点就为我节省了大量开发和调试数据结构的时间。
除此之外,在实施过程中确实没有太多特别的事情发生。有一些棘手的错误需要解决,包括在更新图表中的内容后忘记更新或刷新引用,但我最终让一切正常。
整个东西也存在于一个文件中,这使得它易于设置和使用。我添加了一些方法来将 LinkedGraph
导入/导出到索引三角形的缓冲区 - 我之前使用的原始数据格式。
9、计算单独的着色 + 位移法线
现在回到手头的问题。我们必须处理的网格数据在同一位置有多个顶点,以便于着色。但是,用于位移的法线对于所有重合顶点都必须相同。
为了支持非平滑着色和有效位移,必须计算两组单独的法线。
为了计算位移的法线,需要将那些重合的顶点合并在一起。为了实现这一点,我只是对网格中的每个(顶点,顶点)对进行了强力二次搜索。如果这两个顶点位于完全相同的点,则它们会合并 - 更新所有边和面中的引用以指向第一个,然后从图中删除第二个。
对于我正在处理的网格,这已经足够快了,N^2 性能特征并不重要。毕竟这是在原始低分辨率、预细分网格上运行的。
如果/当我最终将其用于更大、更复杂的网格时,就需要使用一些空间分区数据结构(如 BVH)来加快速度。
10、图关系
由于 LinkedMesh
数据结构在各方面都是图(graph),因此可以将其可视化。我编写了一些代码来构建 LinkedMesh
的 GraphViz 表示 - 最初用于调试目的。
这是同一网格的图表示,其中两个方面从之前开始以直角连接在一起:
请注意,有两个不同的、不相连的子图。每个子图恰好有两个面,因为一个矩形由两个三角形组成。这两个三角形恰好共享一条边,而所有其他边都只包含在一条边中。
如果执行了重合顶点的合并,则结果图如下:
正如预期的那样,子图合并在一起,没有断开的组件。两个合并的顶点现在有 4 条边 - 与我们在 Blender 中对网格进行三角剖分时看到的一致:
11、计算顶点法线
现在所有重合点都已合并到图形表示中,我们可以计算合并顶点上的精确顶点法线,这样移动它们不会导致网格面彼此拉开。
之前,我提到这是通过平均连接面的法线来实现的,这是真的。然而,当我自己实现这个时,我了解到实际上有一个更具体的算法需要遵循。
顶点法线需要通过对连接面的法线取加权平均值来计算,权重是共享该顶点的面的边缘之间的角度。
写下来听起来有点复杂,但实际上非常简单。直观地看到它会有所帮助:
红色和蓝色圆弧标记正在测量的角度,这是计算法线时共享所选顶点的两个面的法线的加权值。
如果不执行加权,法线在某些几何体上会不平衡且不准确,并产生错误/不均匀的着色和位移结果。
12、按角度平滑着色
顶点合并后可获得准确的位移法线,但有关哪些面是平滑的、哪些面是平坦的原始着色数据会丢失。这会使整个网格平滑着色。如果需要任何其他类型的着色,我们必须自己计算。
在计算着色法线时,实际上除了我迄今为止讨论的二进制“平滑”或“平坦”之外还有更多选项。Blender(我相信其他工具也是如此)提供了一个名为“按角度平滑着色”(以前标记为“自动平滑着色”)的选项:
这种着色模型是平滑和平面着色的混合体。它查看面之间的边缘角度,如果角度大于某个可配置阈值,则将边缘标记为“锐利”。这本质上具有复制该边缘顶点的效果 - 反转上一步的合并过程。这允许共享该锐利边缘的面在其上具有不同的法线,并使边缘在渲染时呈现平面着色。
这似乎很简单,对吧?
在我花了整整一个周末试图想出一个从头开始执行此操作的算法之前,我就是这么想的。
我尝试了各种想法,最有希望的是图形遍历类型的方法,我将以图形的形式遍历网格并沿着平滑的面行走并构建彼此都平滑的面的子图。
所有这些方法至少在某些地方都失败了,产生了各种奇怪的错误着色伪影,如下所示:
最后,我不得不承认失败,并寻求专家来找到一种算法。
我在 Blender 源代码中找到了他们为网格执行此操作的 位置,弄清楚了发生了什么,并用 Rust 为 LinkedMesh 自己实现了它。
他们那里的代码相当晦涩难懂,充满了我没有的功能或平台实现细节的处理,但我最终弄清楚了发生了什么。
他们使用的算法非常巧妙。其核心是围绕顶点走动并将面划分为他们所谓的“平滑扇形”。每个平滑扇形都有自己的顶点副本,具有独特的着色法线。
还有更多内容,如果你感兴趣的话,这是关于该算法及其实现方式的专门文章。
这里唯一需要注意的重要一点是,当我复制一个顶点以使其其中一个边缘变得锐利时,先前计算的位移法线将被复制到新的顶点。这确保了无论重复顶点分配了什么着色法线,它们都会以相同的方式位移,并且网格不会被撕裂。
13、程序化位移
好了,到目前为止,我有了以下内容:
- 一种将任意三角形集转换为类似图形的 LinkedMesh 表示的方法
- 一种合并重合顶点和计算位移法线的算法,以使网格在位移时不会撕裂
- 一种计算“按角度平滑”法线以进行着色的算法
- 一种细分生成的 LinkedMesh 以增加几何细节而不改变其形状的算法
现在我们可以实际进行一些位移了。
我决定提前在 CPU 上实现位移/变形,而不是像以前那样在顶点着色器中执行此操作。
这样做有几个原因:
- 它允许运行更复杂/更昂贵的算法。
- 它允许使用有关网格的非本地数据,而不仅仅是单个顶点
也许最有影响力的是,
- 它还允许计算和更新除顶点位置之外的其他内容
如前所述,顶点着色器中的位移贴图受到渲染过程后期发生的事实的限制。如果你在没有相应的法线贴图或其他匹配项的情况下显着位移网格的顶点,结果看起来会很不理想。
由于我现在有了成熟的按角度平滑法线计算代码,我可以随心所欲地进行网格变形,然后从头开始计算准确的法线。
14、基于噪声的位移
我首先尝试的是基于噪声的位移算法。我在网格中每个顶点的位置采样了一个 4 个八度的 3D FBM 噪声场,并根据采样值沿其法线推或拉顶点。
结果如下:
相当成功,如果我自己这么说的话。这是一个有点极端的例子,位移量非常大,但这有助于夸大位移后法线计算对阴影和着色的影响。
15、水晶状突起
我想尝试另一种位移算法,它将有助于利用算法的动态平面着色能力。
我修改了基于噪声的方法,使其根据阈值急剧位移,如下所示:
let displacement = if noise > -0.9 && noise < -0.7 { 1. } else { 0. };
结果如下:
按角度平滑着色的效果已完全显现。
查看尖刺与网格表面连接处的底部。由于突出的角度较大,这些边缘被标记为锐利,结果是面之间形成了漂亮的平面阴影区别。
当我在这里看到结果时,我立即想到了各种其他可以尝试的东西。
我可以多次重复位移过程以在网格上构建越来越多的细节,我可以使用构造实体几何体将多个简单网格组合在一起,然后位移合并的结果……这个系统可以实现很多东西。
不过这篇文章已经很长了,所以我会把所有这些东西留到下次再说。
16、其他注意事项
以下是我在网格处理流程中发现的一些修复或调整小技巧的列表,这些技巧在某些情况下可以使事情变得更好或看起来更好。
- 着色法线计算顺序
我在进行最小或无位移的细分时注意到一件事,有时三角形着色伪影会出现在以平滑角度相交的平面之间的边缘上:
我花了很长时间试图找出我的正常计算算法中是否存在错误或其他问题。但事实证明,这实际上是底层几何体的正确着色行为。当我在 Blender 中手动重新创建类似的几何体时,出现了类似的三角形伪影:
对于某些三角形图案,法线计算的工作方式自然会在光照中产生这些方格图案。
幸运的是,有一种方法可以解决这个问题。对于遇到此问题的网格,我发现在预细分网格上计算着色法线,然后以与位移法线相同的方式插入这些法线可以完全解决问题。
这基本上复制了着色器在渲染一个大三角形并将过渡扩展到整个面时所做的相同行为。
- 保留锐利边缘
我注意到的另一件事是,有时应用变形会导致之前锐利的边缘变得平滑或部分平滑,而平滑的角度法线随后被计算。法线计算代码工作正常,但对于某些网格,结果看起来有点不对劲。
为了解决这个问题,我在位移之前标记了锐利边缘,并在整个细分和变形过程中保留了该锐利标记。
3D 建模师有时会手动执行此操作 - 明确将某些边缘标记为尖锐,即使它们的角度是平滑的 - 以便调整模型的着色方式。以下是它对我测试的一些网格的影响:
效果有点微妙,但它确实有助于使某些网格看起来更干净,并更好地保留其原始结构。
- 位移法线插值方法
如果你还记得,进行此位移的过程涉及合并重合顶点,然后计算这些合并顶点的位移法线。之后,网格被细分以添加更多细节,然后进行位移。
我没有提到的一件事是如何为细分过程创建的新顶点计算位移法线。
我最初的解决方案是仅对分割的每个边的位移法线进行插值,并将其设置为在中间创建的新顶点。
事实证明,这对于我正在进行的变形类型最有效。但是,它可以为几何体创建一种“充气屋”外观。当使用此方法将每个顶点沿其法线向外位移一个常数时,它看起来是这样的:
明白我的意思了吗?它们看起来就像充气过度的气垫床。
公平地说,这是应用的相当极端的位移量,因此对于其他网格来说不会那么明显。
我想到另一种设置细分过程中创建的新顶点的位移法线的方法是将它们设置为边缘法线。只需平均共享边缘的所有面的法线即可提前计算边缘法线。
以下是使用该方法的同一场景的外观:
顶部现在是平的,但几何形状不太平滑,并且在边缘处产生了一些大面。
无论如何,我建议首先使用插值位移法线,并且仅在你的特定位移方法需要时才使用第二种方法。
- UV 贴图 + 三平面贴图
你可能已经注意到,对于我在本文中包含的所有屏幕截图,尽管底层网格严重扭曲,但网格上的纹理并没有出现拉伸或扭曲。
原因是我使用三平面贴图对它们进行纹理处理。三平面贴图是一种可用于纹理网格的算法,根本不需要 UV 贴图。相反,它使用世界空间坐标并对 X、Y 和 Z 轴进行 3 次不同的查找。它根据被纹理化的片段处的网格法线进行插值,从而产生(大部分)平滑且自然的网格表面纹理。
如果不使用三平面贴图,则在进行细分时必须考虑 UV 贴图,并计算位移过程中产生的新顶点的插值 UV 坐标。如果变形程度较轻,则可能可以避免这种情况,但对于更极端的变形,这可能会导致网格上出现可见的拉伸效果。
不过,我强烈建议你尝试三平面贴图;当我第一次开始使用它时,它对我来说非常神奇。据我所知,这几乎是纹理化复杂的程序生成网格的唯一方法。而且它确实非常容易实现;即使是我添加了一些功能和性能优化的花哨实现也只有几十行代码
。