优化了迪士尼动画片“ Moana”中的场景渲染。 第4部分和第5部分

图片

我有一个pbrt分支,该分支用于测试新想法,实现科学文章中有趣的想法,并且通常用于研究通常导致基于物理渲染的新版本的所有内容。 与pbrt-v3不同,我们努力使pbrt-v3与本书中描述的系统保持尽可能的接近,在此线程中,我们可以进行任何更改。 今天,我们将看到系统中更根本的变化将如何显着减少迪士尼动画片“ Moana”中的岛屿场景中的内存使用。

关于方法的注意事项:在前三篇文章中,所有统计数据都是针对我在发布之前使用的场景的WIP版本(进行中的工作)进行测量的。 在本文中,我们将继续使用最终版本,该版本要复杂一些。

Moana渲染最后一个孤岛场景时,使用了81 GB的RAM来存储pbrt-v3的场景描述。 当前,pbrt-next使用41 GB-大约一半。 要获得此结果,进行小的更改就足以扩散到几百行代码中。

减少原语


让我们记住,在pbrt中, Primitive是几何体,其材质,辐射函数(如果是光源)以及表面内部和外部环境记录的组合。 在pbrt-v3中, GeometricPrimitive存储以下内容:

  std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface; 

如前所述 ,大多数时间areaLightnullptr ,而MediumInterface包含一对nullptr 。 因此,在pbrt-next中,我添加了一个称为SimplePrimitivePrimitive选项,该选项仅存储指向几何图形和材质的指针。 尽可能使用它代替GeometricPrimitive

 class SimplePrimitive : public Primitive { // ... std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; }; 

对于非动画对象实例,我们现在有了TransformedPrimitive ,它仅存储指向原语和转换的指针,这可以节省AnimatedTransform实例添加到TransformedPrimitive渲染器pbrt-v3中的约500个字节的浪费空间

 class TransformedPrimitive : public Primitive { // ... std::shared_ptr<Primitive> primitive; std::shared_ptr<Transform> PrimitiveToWorld; }; 

(如果需要将动画转换为pbrt-next,可以使用AnimatedPrimitive 。)

完成所有这些更改后,统计数据报告在Primitive下仅使用了7.8 GB,而不是pbrt-v3中使用了28.9 GB。 尽管我们节省了21 GB很好,但是它并没有我们之前预期的减少那么多; 我们将在本部分结尾处回到这一差异。

减少几何


同样,pbrt-next大大减少了几何所占用的内存量:用于网格三角形的空间从19.4 GB减小到9.9 GB,曲线的存储空间从1.4 GB减小到1.1 GB。 节省的资金中有一半以上来自简化基础Shape类。

在pbrt-v3中, Shape带来了几个继承到所有Shape实现中的成员-这些是可以在Shape实现中方便访问的几个方面。

 class Shape { // .... const Transform *ObjectToWorld, *WorldToObject; const bool reverseOrientation; const bool transformSwapsHandedness; }; 

要了解为什么这些成员变量会引起问题,了解pbrt中三角形网格的表示方式将很有帮助。 首先,有一个TriangleMesh类,它存储整个网格的顶点和索引缓冲区:

 struct TriangleMesh { int nTriangles, nVertices; std::vector<int> vertexIndices; std::unique_ptr<Point3f[]> p; std::unique_ptr<Normal3f[]> n; // ... }; 

网格中的每个三角形都由Triangle类表示,该类继承自Shape 。 这个想法是使Triangle尽可能的小:它们仅存储一个指向它们所组成的网格的指针,以及一个指向其顶点开始于索引缓冲区的偏移量的指针:

 class Triangle : public Shape { // ... std::shared_ptr<TriangleMesh> mesh; const int *v; }; 

Triangle实现需要找到其顶点的位置时,它将执行相应的索引以从TriangleMesh获取它们。

Shape pbrt-v3的问题在于,网格中所有三角形所存储的值都相同,因此最好将每个网格中的值保存到TriangleMesh ,然后让Triangle访问单个公共值副本。

此问题已在pbrt-next中修复:pbrt-next中的基础Shape类不包含此类成员,因此每个Triangle少了24个字节。 几何Curve使用类似的策略,并受益于更紧凑的Shape

共享三角形缓冲区


尽管Moana岛场景广泛使用对象实例化来显式重复几何图形,但我很好奇索引缓冲区,纹理坐标缓冲区等的重用频率用于各种三角形网格的情况。

我编写了一个小类,该类在收到时对这些缓冲区进行哈希处理并将其存储在缓存中,并修改了TriangleMesh以便它检查缓存并使用所需的任何冗余缓冲区的已保存版本。 增益非常好:我设法摆脱了4.7 GB的多余容量,这远远超出了我的预期。

使用std崩溃:: shared_ptr


完成所有这些更改后,统计信息将报告约36 GB的已知分配内存,并且在渲染开始时, top表示已使用53 GB。 事务。

我担心还会发生一系列缓慢的massif 扫描 ,以找出统计信息中缺少哪些分配的内存,但是随后我的收件箱中出现了Arseny Kapulkin的来信。 Arseny向我解释说, 我以前GeometricPrimitive内存使用情况的估计是非常错误的。 我不得不花很长时间才弄清楚,但是后来我意识到了。 非常感谢Arseny指出错误和详细说明。

在写给Arseny之前,我曾设想过std::shared_ptr的实现如下:在这些行中,有一个公共描述符,用于存储引用计数和指向放置对象本身的指针:

 template <typename T> class shared_ptr_info { std::atomic<int> refCount; T *ptr; }; 

然后我建议shared_ptr实例指向它并使用它:

 template <typename T> class shared_ptr { // ... T *operator->() { return info->ptr; } shared_ptr_info<T> *info; }; 

简而言之,我假设sizeof(shared_ptr<>)与指针的大小相同,并且每个共享指针上浪费了16个字节的额外空间。

但是事实并非如此。

在我的系统实现中,公共描述符的大小为32个字节,大小为16个字节sizeof(shared_ptr<>) 。 因此,主要由std::shared_ptr组成的GeometricPrimitive大约是我估计的两倍。 如果您想知道为什么会发生这种情况,那么这两个Stack Overflow帖子将详细解释原因: 12

在pbrt-next中几乎所有使用std::shared_ptr的情况下,都不必共享指针。 在进行疯狂的黑客攻击时,我将所有可能的东西替换为std::unique_ptr ,实际上它的大小与常规指针相同。 例如,这是SimplePrimitive现在的样子:

 class SimplePrimitive : public Primitive { // ... std::unique_ptr<Shape> shape; const Material *material; }; 

结果得到的回报比我预期的要大:渲染开始时的内存使用量从53 GB减少到41 GB-节省了12 GB,几天前完全出乎意料,总容量几乎是pbrt-v3使用的一半。 太好了!

在下一部分中,我们将最终完成本系列的文章-在pbrt-next中检查渲染速度,并讨论其他方法以减少该场景所需的内存量。

第5部分


为了总结本系列文章,我们将从探索pbrt-next(我用来测试新想法的pbrt分支)中的迪士尼动画片“ Moana”中的岛屿场景的渲染速度开始。 我们将做出比pbrt-v3可能进行的更根本的更改,而pbrt-v3应该遵循我们书中描述的系统。 最后,我们讨论了从最简单到最极端的需要进一步改进的领域。

渲染时间


Pbrt-next对光传输算法进行了许多更改,包括对BSDF采样的更改以及对俄罗斯轮盘赌算法的改进。 结果,它比pbrt-v3跟踪更多的光线以渲染该场景,因此无法直接比较这两个渲染器的执行时间。 速度通常很接近,但有一个重要的例外:当从Moana渲染孤岛场景(如下所示)时,pbrt-v3将花费执行时间的14.5%来搜索ptex纹理。 这对我来说似乎很正常,但是pbrt-next仅花费执行时间的2.​​2%。 所有这些都非常有趣。

研究统计数据后,得出1

pbrt-v3:
Ptex 20828624
Ptex 712324767

pbrt-next:
Ptex 3378524
Ptex 825826507


正如我们在pbrt-v3中看到的那样,平均每34次纹理搜索从磁盘读取一次ptex纹理。 在pbrt-next中,仅在每244次搜索后才将其读出-也就是说,磁盘I / O减少了大约7倍。 我之所以提出这种情况,是因为pbrt-next计算了间接射线的射线差异,这导致可以访问更高的MIP纹理级别,进而创建了一系列对ptex纹理缓存的集成程度更高的访问,减少高速缓存未命中的次数,从而减少I / O操作的次数2 。 简短的检查证实了我的猜测:当关闭光束差时,ptex速度变得更差。

ptex速度的提高不仅影响了计算和I / O的成本。 在32 CPU系统中,pbrt-v3在解析场景描述后仅加速了14.9倍。 pbrt通常显示接近线性并行缩放,因此我非常失望。 由于ptex锁定期间的冲突数量要少得多,因此在32个CPU的系统中,pbrt-next版本的速度快29.2倍,在96个CPU的系统中pbrt-next版本的速度快94.9倍-我们回到了适合自己的指标。


pbrt渲染的Moana岛场景的根,分辨率为2048x858,每个像素256个样本。 在pbrt-next中,具有96个虚拟CPU(频率为2 GHz)的Google Compute Engine实例上的总渲染时间为41分钟22 s。 渲染期间由于多线程导致的加速是94.9倍。 (我不太了解凹凸贴图的情况。)

为未来而努力


减少在如此复杂的场景中使用的内存量是一种令人兴奋的体验:通过少量更改节省几GB的内存比在一个简单的场景中节省数十兆的内存令人愉悦。 如果时间允许,我有一份很好的清单,希望将来能学到什么。 这是一个快速概述。

进一步减少三角形缓冲存储器


即使重复使用为多个三角形网格存储相同值的缓冲区,三角形缓冲区下仍会使用大量内存。 这是场景中各种类型的三角形缓冲区的内存使用情况的细分:

型式记忆
订单项2.5 GB
正常的2.5 GB
紫外线98兆字节
指标252兆字节

我知道传输的顶点位置无法做任何事情,但是对于其他数据,可以节省很多。 内存有效形式的法向向量有多种表示形式 ,可在内存大小/计算次数之间进行各种折衷。 使用24位或32位表示形式之一会将法线所占用的空间减少到663 MB和864 MB,这将为我们节省超过1.5 GB的RAM。

在此场景中,用于存储纹理坐标和索引缓冲区的内存量非常少。 我认为发生这种情况的原因是场景中存在许多程序生成的植物,并且同一植物类型的所有变体都具有相同的拓扑(因此具有索引缓冲区),并带有参数化(因此具有UV坐标)。 反过来,重用匹配缓冲区非常有效。

对于其他场景,根据其值的范围,对纹理的16位UV坐标进行采样或使用半精度浮点值可能非常适合。 在该场景中,所有纹理坐标值似乎都是零或一,这意味着它们可以用一位表示-也就是说,可以将占用的内存减少32倍。 这种情况的出现可能是由于使用ptex格式进行纹理处理而消除了对UV地图集的需要。 考虑到当前由纹理坐标占用的少量内容,执行此优化并不是特别必要。

pbrt始终将32位整数用于索引缓冲区。 对于小于256个顶点的小网格,每个索引仅8位就足够了;对于小于65,536个顶点的网格,可以使用16位。 更改pbrt以使其适应这种格式并不是很困难。 如果我们想最大程度地进行优化,我们可以选择所需数量的位来表示索引中所需的范围,而代价将是增加寻找其值的复杂性。 尽管事实是现在顶点索引仅使用了四分之一GB的内存,但是与其他任务相比,此任务看起来并不十分有趣。

BVH峰值构建内存使用率


之前,我们没有讨论内存使用的其他细节:在渲染之前,即将出现10 GB额外使用的内存的短期峰值。 在构建整个场景的(大)BVH时会发生这种情况。 用于构建pbrt渲染器的BVH的代码被编写为分两个阶段执行:首先,它使用传统表示形式创建BVH:指向每个节点的两个子指针。 构造完树之后,将其转换为内存有效方案 ,其中该节点的第一个子节点直接位于内存中的后面,并且与第二个子节点的偏移量存储为整数。

从教授学生的角度来看,这种分离是必要的-理解BVH的算法要容易得多,而又不会因在构建过程中将树转换为紧凑形式而引起混乱。 但是,结果是内存使用量达到峰值。 考虑到它对现场的影响,消除这一问题似乎很有吸引力。

将指针转换为整数


在各种数据结构中,有许多64位指针可以表示为32位整数。 例如,每个SimplePrimitive包含一个指向Material的指针。 Material大多数实例对于场景中的许多图元是通用的,并且永远不会超过数千个; 因此,我们可以存储所有材料的单个全局矢量:

 std::vector<Material *> allMaterials; 

并将此向量的32位整数偏移量存储在SimplePrimitive ,这可以节省4个字节。 可以在每个Triangle中以及在许多其他地方使用指向TriangleMesh的指针使用相同的技巧。

进行了这样的更改后,访问标牌本身会有一些冗余,并且该系统对于试图理解其工作的学生而言将变得难以理解。 此外,在pbrt的情况下,最好以更易于理解的方式实现,尽管这样做会以不完全优化内存使用为代价。

基于竞技场(区域)的住宿


对于每个单独的Triangle和图元,都会对new进行单独的调用(实际上是make_unique ,但这是相同的)。 这样的内存分配导致使用额外的资源记帐,占用了大约5 GB的内存,而这些内存在统计中没有被考虑。 由于所有此类放置的生命周期都是相同的-在渲染完成之前-我们可以通过从内存领域中选择它们来摆脱这种额外的负担

卡其色vtable


我的最后一个主意很糟糕,对此我深表歉意,但她对我很感兴趣。

场景中的每个三角形都有至少两个vtable指针的额外负载:一个用于Triangle ,另一个用于SimplePrimitive 。 这是16个字节。 Moana岛场景共有146 162 124个唯一的三角形,其中增加了近2.2 GB的冗余vtable指针。

如果我们没有Shape的抽象基类并且每个几何实现都不继承任何东西怎么办? 这将为我们节省vtable指针的空间,但是,当然,当将指针传递给几何时,我们将不知道它是哪种几何,也就是说,它将是无用的。

事实证明,在现代x86 CPU上实际上仅使用48位的64位指针 。 因此,我们可以借用额外的16位来存储一些信息……例如,例如我们指向的几何图形。 反过来,通过增加一些工作,我们可以让路回到创建虚拟函数调用模拟的可能性。

这是如何发生的:首先,我们定义一个ShapeMethods结构,该结构包含指向函数的指针,例如3

 struct ShapeMethods { Bounds3f (*WorldBound)(void *); // Intersect, etc. ... }; 

每个几何实现都将实现约束函数,交集函数等,并接收this指针的类似物作为第一个参数:

 Bounds3f TriangleWorldBound(void *t) { //       Triangle. Triangle *tri = (Triangle *)t; // ... 

我们将拥有一个ShapeMethods结构的全局表,其中第n个元素用于索引为n的几何类型:

 ShapeMethods shapeMethods[] = { { TriangleWorldBound, /*...*/ }, { CurveWorldBound, /*...*/ }; // ... }; 

创建几何时,我们将其类型编码为返回指针的一些未使用的位。 然后,考虑到指向要执行其特定调用的几何的指​​针,我们将从指针中提取此类型索引,并将其用作shapeMethods的索引以查找相应的函数指针。 本质上,我们将手动实现vtable,自行处理调度。 如果我们对几何体和图元都这样做,则每个Triangle可以节省16个字节,但与此同时,我们采用了相当困难的方法。

我想这种实现虚拟功能管理的技巧并不是什么新鲜事,但是我在Internet上找不到与之链接。 这是有关标记指针的Wikipedia页面,但它确实会查看链接计数之类的内容。 如果您知道更好的链接,请给我一封信。

通过分享这个尴尬的技巧,我可以完成一系列的帖子。 再次感谢迪斯尼发布了这一幕。 与之合作非常有趣。 我脑海中的齿轮不断旋转。

注意事项


  1. 最后,与pbrt-v3相比,pbrt-next在此场景中跟踪的光线更多,这可能解释了搜索操作数量的增加。
  2. pbrt-next中间接光线的光线差异是使用pbrt-v3的纹理缓存扩展中使用的hack来计算的。 , , .
  3. Rayshade . , C . Rayshade .

Source: https://habr.com/ru/post/zh-CN417939/


All Articles