我有一个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;
如前所述 ,大多数时间
areaLight
是
nullptr
,而
MediumInterface
包含一对
nullptr
。 因此,在pbrt-next中,我添加了一个称为
SimplePrimitive
的
Primitive
选项,该选项仅存储指向几何图形和材质的指针。 尽可能使用它代替
GeometricPrimitive
:
class SimplePrimitive : public Primitive {
对于非动画对象实例,我们现在有了
TransformedPrimitive
,它仅存储指向原语和转换的指针,这可以节省
AnimatedTransform
实例添加到
TransformedPrimitive
渲染器pbrt-v3中的约500个字节的
浪费空间 。
class TransformedPrimitive : public Primitive {
(如果需要将动画转换为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 {
要了解为什么这些成员变量会引起问题,了解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 {
当
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 {
简而言之,我假设
sizeof(shared_ptr<>)
与指针的大小相同,并且每个共享指针上浪费了16个字节的额外空间。
但是事实并非如此。
在我的系统实现中,公共描述符的大小为32个字节,大小为16个字节
sizeof(shared_ptr<>)
。 因此,主要由
std::shared_ptr
组成的
GeometricPrimitive
大约是我估计的两倍。 如果您想知道为什么会发生这种情况,那么这两个Stack Overflow帖子将详细解释原因:
1和
2 。
在pbrt-next中几乎所有使用
std::shared_ptr
的情况下,都不必共享指针。 在进行疯狂的黑客攻击时,我将所有可能的东西替换为
std::unique_ptr
,实际上它的大小与常规指针相同。 例如,这是
SimplePrimitive
现在的样子:
class SimplePrimitive : public Primitive {
结果得到的回报比我预期的要大:渲染开始时的内存使用量从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 *);
每个几何实现都将实现约束函数,交集函数等,并接收
this
指针的类似物作为第一个参数:
Bounds3f TriangleWorldBound(void *t) {
我们将拥有一个
ShapeMethods
结构的全局表,其中第
n个元素用于索引为
n的几何类型:
ShapeMethods shapeMethods[] = { { TriangleWorldBound, }, { CurveWorldBound, };
创建几何时,我们将其类型编码为返回指针的一些未使用的位。 然后,考虑到指向要执行其特定调用的几何的指针,我们将从指针中提取此类型索引,并将其用作
shapeMethods
的索引以查找相应的函数指针。 本质上,我们将手动实现vtable,自行处理调度。 如果我们对几何体和图元都这样做,则每个
Triangle
可以节省16个字节,但与此同时,我们采用了相当困难的方法。
我想这种实现虚拟功能管理的技巧并不是什么新鲜事,但是我在Internet上找不到与之链接。 这是有关
标记指针的Wikipedia页面,但它确实会查看链接计数之类的内容。 如果您知道更好的链接,请给我一封信。
通过分享这个尴尬的技巧,我可以完成一系列的帖子。 再次感谢迪斯尼发布了这一幕。 与之合作非常有趣。 我脑海中的齿轮不断旋转。
注意事项
- 最后,与pbrt-v3相比,pbrt-next在此场景中跟踪的光线更多,这可能解释了搜索操作数量的增加。
- pbrt-next中间接光线的光线差异是使用pbrt-v3的纹理缓存扩展中使用的hack来计算的。 , , .
- Rayshade . , C . Rayshade .