移动 VR 开发时要避免的 PC 渲染技术
更新:本文是为 Quest 1 开发人员编写的。虽然 Quest 2 建立在相同的架构上,但现在更容易为阴影贴图(以及其他需要从先前渲染过程中生成的纹理读取的简单技术)做预算。
尽管移动芯片组可以支持下面概述的大多数技术,但我们强烈建议你不要这样做。不过,这并不总是一成不变的规则,因为我有看到开发者有实现下述技术但依然达到帧速率的要求。但通过避免下文提及的 PC 渲染技术,你会避免很多麻烦。
延迟渲染
延迟渲染(或(延迟 shader)[https://en.wikipedia.org/wiki/Deferred_shading]), 这种技术是将光照/渲染计算推迟到第二步进行计算。这样做的目的是为了避免多次渲染同一个像素。延迟渲染主要分为两步:在第一步中,渲染场景,但只是简单地将几何信息(位置坐标,法线向量,纹理坐标和反射系数等等)存储在中间缓冲区中;在第二步中,从中间缓冲区读取信息,应用反射模型,计算出每个像素的最终颜色。
延迟渲染对 PC 开发非常有效,因为它可以将几何图形与照明分离,你只需更新每个照明所触达的像素,即可在更少的 GPU 周期内渲染更多照明。
为何不适合移动开发?
原因有很多,但主要原因是 resolve 成本问题。什么是 resolve 成本?在我告诉你什么是 resolve 成本之前,你首先需要了解 tiled GPU 的工作原理。
为了以更低功耗实现更高的吞吐量,移动 GPU(例如 Oculus Quest 中使用的 Snapdragon 835)通常使用 tiled 架构,其中每个渲染目标都被分解成块状网格或“tiles” (从 16x16 像素到 256x256 像素的任何位置,具体取决于硬件和像素格式)。所谓 Tile,就是将几何数据转换成小矩形区域的过程,然后提交给异步处理器,异步处理器执行渲染工作以计算每个“tile”的图像结果。一旦计算出每个图块图像,GPU 必须从片上内存中将图块复制回通用内存。这实际上非常慢,因为它需要通过总线传输数据。我们将此转移称为“resolve”,因此所需的时间称为“resolve 成本”。维基百科对[tiled rendering][https://en.wikipedia.org/wiki/tiled_rendering]有更详细的描述,供进一步阅读。
因为要渲染的每个 texture 都需要 resolve,并且延迟渲染需要在计算照明之前渲染大量纹理,所以你的 resolve 成本将从正向渲染的大约 1ms 增加至 3ms 以上。如你所见,你可能没有这样的时间。
除了 resolve 成本之外,延迟渲染仅在你的几何体复杂且有多光照时才有优势。无论如何,这两者都无法在移动设备上真正实现,因为 GPU 处理大量顶点和计算像素填充的能力有限。
暂时的答案是坚持使用 forward 渲染。也可能有一个好的 forward+ 实现的地方,虽然我还没有看到一个。
Depth pre-pass
depth/Z pre-pass 是一种常用技术,其中所有场景几何图形都作为第一步渲染,而不填充帧缓冲区,仅生成深度缓冲区值。然后渲染第二步,检查每个像素的计算深度是否等于深度缓冲区中该像素的值。如果不是,你可以跳过对该片段进行着色。由于在 PC 上处理顶点往往比着色像素快得多,因此可以节省大量时间。
为何不适合移动开发?
首先,如果在提交绘制调用之前对几何进行排序,通过进行 Depth Pre-Pass 而节省的片段填充时间应该最少。来回绘制会导致常规深度测试拒绝你的像素,所以你只能避免对未正确排序或两个对象在不同点彼此重叠的几何图形进行像素填充。
其次,这需要绘制调用加倍,因为你必须先提交所有内容以进行 Depth Pre-Pass,然后再才是 Forward Pass。由于绘制调用对 cpu 的占用非常大,所以你需要避免这种情况。
第三,所有顶点都需要处理两次,这通常会增加 GPU 时间,而不是避免两次填充几个像素所节省的时间。这是因为顶点处理在移动设备上比在 PC 上花费的时间相对更多,并且处理片段相对较少(因为帧缓冲区大小通常较小,片段程序往往不那么复杂)。
第三,所有顶点都需要处理两次,与通过避免填充少数像素两次而节省的时间相比,你通常增加的 GPU 时间会更多。这是因为移动设备的顶点处理会在 PC 耗费更多的时间,并且处理片段的时间同样相对较少(因为 framebuffer 大小通常较小,fragment program 往往不那么复杂)。
HDR textures
resolve 成本与图像中的字节数直接相关,而不与像素数直接相关,所以,尽管我们通常以 32 位 RGBA 像素来思考量度,但如今大多数开发者都在使用 HDR 纹理,即每像素 64 位。这会将你的 resolve 成本增加一倍,并且由于显示器仅支持每通道 8 位,所以你在 resolve HDR 纹理时会浪费大量时间。更不用说移动 GPU 是针对 32 位帧缓冲区进行优化。
后处理
后处理是一种经常用于为游戏施加多种效果的技术,如 Color Grading,Bloom 照明和运动模糊。具体的实现方式是获取游戏渲染的输出,然后对图像运行全屏通道以产生新图像,然后再将其呈现给玩家。一些后期处理效果是作为一个额外的通道执行(如颜色分级),另一些则需要多个通道。
对于移动设备,后处理的主要问题同样是解析成本。生成第二张图像将引起另一次解析,并会立即消耗大约 1 毫秒的时间。更不用说计算后处理效果所花费的时间,取决于效果,这可能会占用大量资源。所以,最好避免进行后处理。
以下是替代常见后处理效果的方案:
Color grading
与其在后处理中执行 Color Grading,不如在每个片段着色器的末尾添加一个函数调用以执行相同的数学运算。这将产生相同的视觉结果,但无需额外的解析。
Bloom
真正的 Bloom 效果非常耗时。最好的选择是“伪造”。采用包含 blob 纹理的 billboarding sprite 可以产生非常接近的效果。
实时阴影
我认为这是最有争议的一项技术。一系列具有完整实时阴影的应用已成功支持移动设备。但是,这样做存在大量的折衷,而我认为值得避免使用。
实时阴影的一种常用技术是级联阴影贴图,这意味着场景会以各种视口大小进行多次渲染。对于必须由 GPU 处理的几何,这会令次数增加 1 到 4 倍,这从根本上限制了场景可以支持的顶点数量。它同时增加了阴影贴图纹理的解析成本(与纹理大小有关)。在 GPU 管道的另一端,在对阴影贴图进行采样时有两个选项:硬阴影和软阴影。硬阴影可以更快地渲染,但具有不可避免的锯齿问题。
由于阴影贴图的工作方式,这个测试只能得出二进制结果。你无法对阴影贴图进行双线性采样,因为它表示深度值而非颜色值。应该避免使用软阴影,因为它们需要将多个采样放到阴影贴图中,而这当然很慢。最好的选择是烘烤所有可能的阴影,而如果需要实时阴影,请寻找另一种方法。如果照明大部分都是漫反射,则通常可以接受 blob 阴影。如果需要强光照明并且阴影表面是平面,则几何阴影的效果同样相当出色。
深度(及帧缓冲区)采样
对于 PC,你可以在着色器中采样当前的深度纹理(Unity 将其显示为_CameraDepthTexture)。之所以可行,是因为深度纹理只是 PC 上的另一种纹理,并且由于每个绘制调用都接连发生,所以深度纹理的状态将是上一次绘制调用之后的状态。但对于基于图块渲染,当前深度不在纹理之中,而是仅存储在你的图块内存中,所以无法将其作为普通纹理进行采样。
考虑到上述情况,有一个 GLES 扩展可允许你查询深度缓冲区(和帧缓冲区)的当前状态。问题是它们非常慢,只能支持你对相同像素的值进行采样(无法查询附近的像素),并且在启用 MSAA 时它们会产生一系列的问题。
启用 MSAA 时,图块实际上具有一个足够大,能够容纳所有采样的缓冲区(即 2×MSAA 的像素为 2 倍,4×MSAA 的像素为 4 倍)。这意味着默认情况下,如果对深度缓冲区进行采样,则必须按每个采样执行片段着色器,这意味着时间密集度将比预期高 2 倍或 4 倍。存在一种“解决方案”,即调用 glDisable(FETCH_PER_SAMPLE_ARM)。但这样做的问题是,它将仅检索第一个采样的值,而不是混合采样的结果,所以在启用所述功能后,MSAA 将被禁用。
除非绝对必要,否则你应避免它们对帧时间产生的影响。
几何着色器
几何着色器允许你在运行时生成额外的顶点,这对于诸如动态细分等功能十分有用。但是,对于基于图块渲染的 GPU 而言,几何着色器会产生问题。生成额外顶点的步骤阻止了合并过程的进行,这意味着 GPU 不能这样做,所以它会切换为“立即”模式(完全跳过分块过程)。可以猜到,这非常缓慢。所以,最好避免使用几何着色器,并且如果有必要,选择 CPU 生成顶点。
Mirrors/Portals
如果你用天真的方式实现它们……对于“天真的方式”,我是指分配两个眼睛缓冲区大小的纹理,计算反射矩阵,然后将场景渲染到两个纹理中。然后,你的 mirror 几何将进行屏幕空间纹理采样,从而显示反射。这种方法存在众多明显的缺陷:
- 绘制调用增加了两倍。
- 填充的像素比屏幕可见的像素要多。
- 必须解析另外两个纹理。
我发现的最低提升是限制了 mirror camera 的视口,并更改了相应的投影矩阵,只能在视锥中渲染 camera 平面边界框。这对上面的第二点问题有所帮助。理想情况下,你同时可以使用多视图,通过一组绘制调用来渲染左右眼,但 Unity 目前不支持这项功能,它不能解决上面的第三点问题 ,并且会令第二点问题更加恶化,因为你只能为两只眼睛使用单个视口,所以你必须使用两个 mirror 边界框的重叠。所以,理想的解决方案将首先解决第三点问题,这意味着一次绘制 mirror 场景和非 mirror 场景。
有一种解决方案可以利用修改后的着色器和模板缓冲区。场景中的每种材质都将具有两种版本的着色器,一种仅在模板缓冲区中的特定位为 0 时绘制,而另一种仅在 1 时绘制。然后,你将使用材质绘制 mirror 网格。它会在模具缓冲区中设置所述位,使用第一组着色器绘制场景,使用反射矩阵设置 camera,并且在最后使用第二组着色器绘制场景。这将产生你想要的反射,同时不会填充超出所需像素的像素,并且避免了不必要的解析。然而,它无法避免绘制一堆对象两次(任何解决方案都无法避免)。
尽管这听起来很容易,但如果是使用 Unity,你将会遇到很多问题(我在 Unreal 中没有遇到过,但你可能会遇到类似的挑战)。首先,在启用 Single Pass Stereo(多视图)后,Unity 将不允许你修改 camera 的投影矩阵,所以你不能使用反射 camera(如果关心 CPU 性能,你绝对应该使用这个 camera)。其次,这没有考虑 Late-Latching(在渲染线程启动时更新 camera 矩阵,从而尽可能减少延迟)。通常来说,这是一次纯粹的胜利,但如果你使用 mirror camera,则反射 camera 的变形将不再与头部变形匹配,所以你会得到奇怪的伪影,镜面中的元素不会按照预期方式排列。
最简单的解决方案是“伪造”。如果你的镜面是静态,则只需创建所有世界几何的反射副本,然后将其放在场景中即可。你需要使用脚本来移动任何动态对象的“反射”副本,从而模仿包括玩家在内的“真实”版本位置,但这将是最快、最简单的渲染解决方案,无需复杂的矩阵数学。如果可以看到镜子后面,你将不得不使用两组具有不同模板蒙版的着色器,但如果玩家由于墙壁等原因而无法看到后面,则可以只保留一组着色器。
总结
无论你是从零开始一个新项目,还是要从 PC 移植到移动设备,明确哪里可以沿用原有的知识经验,哪里又需要采取创新的解决方案是获得最佳游戏效果的关键。 但是,你不必遵循本文的建议。请自由探索,并寻找最适合自己的方案。
原文:https://developer.oculus.com/blog/pc-rendering-techniques-to-avoid-when-developing-for-mobile-vr/