NSDT工具推荐Three.js AI纹理开发包 - YOLO合成数据生成器 - GLTF/GLB在线编辑 - 3D模型格式在线转换 - 可编程3D场景编辑器 - REVIT导出3D模型插件 - 3D模型语义搜索引擎 - Three.js虚拟轴心开发包 - 3D模型在线减面 - STL模型在线切割

最近,我一直在使用 Three.JS 在浏览器中开发一些 3D 项目。目前主要是为了学习和娱乐,熟悉用 GLSL 编写着色器、用 Blender 进行 3D 建模以及所有这些。

现代生态系统非常丰富,WebGL 是一个成熟的平台,因此有大量预构建的着色器、后处理效果和工具可供选择。将所有这些与 Wasm 编译的物理引擎组合在一起,到目前为止,即使我的 Blender 技能非常业余,我也取得了一些非常好的结果:

1、昂贵的片段着色器

我引入并集成到项目中的资源之一是基于十六进制的无缝平铺着色器。在大面积上重复的纹理会导致非常明显的图案可见,即使纹理是无缝的。十六进制平铺算法使用一种非常巧妙的技术,它使用六边形网格转换坐标,然后在三个相邻的六边形之间进行采样和混合。这会合成一个新的纹理,该纹理在视觉上与旧纹理相同,但没有任何可察觉的平铺图案。

最棒的是,它在片段着色器中实时完成所有这些操作!您只需发送和加载普通纹理,无平铺版本就会神奇地出现在您的场景中。

我从 Shadertoy(它本身是一篇研究论文的实现)中获取代码,并将其改编为在 Three.JS PBR 着色器材质中工作。它在我的项目中的样子如下:

我对它的出色表现印象深刻。我尝试过的所有纹理都运行良好,质量令人惊叹。

然而,这是有代价的。该算法在计算上非常昂贵。除了需要进行一些数学运算来计算转换后的坐标外,它还需要执行三次纹理查找而不是一次,并且这些获取的局部性不是很好,这会导致缓存抖动。

对我来说,这个成本是值得的,我很乐意将它包含在我的着色器中,用于地面或墙壁等。然而,我注意到,随着我继续充实我的关卡并添加更多网格,性能不断下降。即使盯着一堵墙,墙后面的东西仍然会被渲染,并且会耗费大量资源。

2、现有的 3D 渲染优化

一如既往,加速计算机的最有效方法是找到减少计算的方法。在 3D 图形中,实现这一点的主要方法是通过不同类型的剔除 - 确定哪些对象不需要渲染,然后就不渲染它们。

首先,WebGL/OpenGL 原生实现了背面剔除。如果您设置了正确的标志,则只会渲染面向相机的三角形。只要您确保三角形在几何图形上朝向正确的方向,那么这是一个不错的免费入门工具。

另一种非常简单的类型称为视锥剔除。这会获取相机可以看到的世界区域,将其与场景中每个对象的边界框相交,并跳过渲染与相机的视框或视锥不相交的所有内容。执行这些检查非常便宜且非常容易实现,Three.JS 实际上内置并自动启用了此功能。

但是,这对其他情况没有帮助,例如我所说的墙后面的所有对象仍在渲染的情况。为了处理这个问题,游戏和 3D 引擎使用遮挡剔除。这涉及在渲染每一帧之前执行一些计算,以确定哪些对象被其他对象完全覆盖(遮挡),并将其排除在渲染之外。不过,这要困难得多,而且 CPU 消耗大。

虽然这确实可以解决问题,但我找不到 Three.JS 的遮挡剔除实现,我也不想尝试自己实现它。它还增加了 CPU 方面的成本,并存在一些其他缺点。例如,如果大型网格中哪怕只有一个像素可见,那么整个网格仍将被渲染。

3、剔除妥协

在研究优化着色器的解决方案时,我偶然发现了一个看起来不错的候选方案,称为早期片段测试。OpenGL 文档页面有一些非常鼓舞人心的内容,看起来它真的可以帮助我的用例。

此优化的前提是,它将在渲染每个片段时查看深度缓冲区,并检查当前正在计算的片段是否比已经渲染的片段“更深”。较大的深度值意味着当前片段位于另一个片段后面,并且除了透明度等某些情况外,不可见。在这种情况下,可以完全跳过片段着色器。

文档页面列出了一个神奇的语句,您可以将其放在着色器的开头,以强制启用这些着色器:

layout(early_fragment_tests) in;

我尝试将其放入 Three.JS 生成的着色器中,但事实证明它仅在 GL 版本 3.1 及更高版本中可用。 WebGL 2 使用 GL 版本 3.0,这意味着这对我来说不起作用。

结果令人失望;早期的片段测试似乎是我的情况的理想解决方案。由于片段着色器是迄今为止我的应用程序中最昂贵的部分,因此能够跳过运行它应该会大大减轻渲染负载。虽然文档页面提到这种优化在某些情况下可能会自动启用,但我在我的应用程序中看到的性能并没有让它看起来在我的情况下有效。

但就在我即将放弃搜索时,我偶然发现了一个 Stack Exchange 问题,它似乎可以提供解决方案。

提出的解决方案实际上非常简单,所以我只将其包含在这里:

  • 使用直接的片段着色器进行深度传递
  • 然后在第二遍中根据深度纹理的值测试 gl_FragCoord.z。

看起来很简单!这实现了与早期片段测试相同的行为,但手动且明确地执行。

我们必须付出两次渲染整个场景的代价,但第一次我们只运行一个极其简单的片段着色器,并且只记录场景中每个片段的最浅深度。这几乎与阴影投射光用于确定场景的哪些部分处于阴影中以及哪些部分接收光线的方法相同。

第二遍使用所有现有着色器并像平常一样进行,但添加了在片段着色器开头运行的一点代码。它从计算的深度缓冲区读取并将当前片段的深度与该像素的最浅深度进行比较。如果当前片段更深,那么我们可以完全跳过渲染它!

4、Three.JS 实现

为了在 Three.JS 中实现这一点,我首先使用后处理库来设置多步骤渲染管道。该库提供了一个预构建的 DepthPass,它完全满足了我们第一步所需的要求:

const composer = new EffectComposer(viz.renderer);

const depthPass = new DepthPass(viz.scene, viz.camera);
depthPass.renderToScreen = false;

const depthTexture = depthPass.texture;
depthTexture.wrapS = THREE.RepeatWrapping;
depthTexture.wrapT = THREE.RepeatWrapping;
depthTexture.generateMipmaps = false;
depthTexture.magFilter = THREE.NearestFilter;
depthTexture.minFilter = THREE.NearestFilter;

与场景本身相比,如果渲染为 RGBA,深度通道的输出如下所示:

深度通道输出中奇怪的颜色和条带是由于深度着色器使用的打包方案,通过将记录的深度数据组合到输出纹理的多个通道中来增加其分辨率。

现在我们有了深度纹理,我们需要将其绑定到我们想要执行手动基于深度的片段剔除的所有着色器。我们还绑定了屏幕的分辨率,以便我们可以计算要读取的纹理坐标:

const mat = new THREE.ShaderMaterial(...);

mat.uniforms.tDepth = { value: depthTexture };
mat.uniforms.iResolution = {
  value: new THREE.Vector2(viz.renderer.domElement.width, viz.renderer.domElement.height),
};

请注意,如果调整了画布大小,则必须小心更新分辨率。

最后一步是编写着色器代码来执行测试。我们首先设置上一步绑定的另一侧。使用这些,我们可以从深度传递计算的纹理中读取屏幕上当前像素的深度值:

uniform sampler2D tDepth;
uniform vec2 iResolution;

float readDepth() {
  vec2 depthUV = vec2(gl_FragCoord.x / iResolution.x, gl_FragCoord.y / iResolution.y);
  vec4 sampledDepth = texture2D(tDepth, depthUV);
  // Use a function provided by Three.JS to unpack the depth data into a single
  // floating point number from 0 to 1
  return unpackRGBAToDepth(sampledDepth);
}

剩下的就是实现手动深度测试本身并首先运行它,以便我们能够尽早丢弃不必要的片段:

void main() {
  // This comes from the depth pre-pass; we know the depth of the closest fragment that will
  // be rendered here.
  float sceneDepth = readDepth();
  float fragDepth = gl_FragCoord.z;

  // It is expected that fragDepth will be less than or equal to sceneDepth. If it is not,
  // then we are rendering a fragment that is behind the closest fragment that will be
  // rendered here.
  float tolerance = 0.0001;
  float depthDiff = (sceneDepth + tolerance) - fragDepth;

  if (depthDiff < 0.) {
    discard;
  }

  <rest of shader code goes here>
}

我在设置时尝试过一件事,就是翻转深度测试中的运算符,以仅丢弃未被遮挡的片段。这可以仅渲染理论上可以通过此优化避免渲染的片段:

在完成所有这些设置并调试了一些杂项错误和问题后,我终于再次获得了场景渲染!它看起来……和以前一模一样 - 这正是我们想要的,因为这只是一次优化。

不过,有一个有趣的错误涉及透明物体。在这种情况下,我们实际上确实希望渲染更深的片段,因为它们可能会透过顶部片段的透明部分显示出来。但是,深度通道并不关心并丢弃所有较低的片段。这导致了一些非常有趣的效果。红色线框球体实际上是完全透明的,但错误的剔除导致它显示如下:

背景中的黑色和粉红色结构仍然会渲染,因为它使用了不同的材质,而手动深度剔除并未实现。

解决方案是稍微修改后处理库中的 DepthPass,以排除透明对象在渲染过程中的渲染。这会将它们排除在基于深度的手动片段剔除之外,从而解决问题。

5、结果

现在最重要的问题是:这对性能有何影响?

标题是手动基于深度的片段剔除有所帮助,并且似乎将我的 GPU 渲染场景所需的工作减少了约 20%

细节有点模糊。事实证明,要得到准确的答案并不容易。即使没有启用优化,在 1440p 显示器上以 165 FPS 渲染场景时,我的 GPU 也只能运行不到一半的容量。毕竟这是一个简单的场景。

我在 Reddit 和 Three.JS Discord 服务器上询问过,但我找不到任何好的分析工具来帮助解决这个问题。我已经习惯了使用许多用于对 CPU 进行性能分析和分析的工具,但我真的找不到任何能在 GPU 方面提供这种体验的工具。

我在这方面取得的最佳测量成果是名为 radeontop 的 CLI 应用程序。它显示了我的 AMD GPU 的一些基本统计数据,包括时钟速度缩放和图形管道利用率。通过一起查看这些,我能够粗略地衡量渲染场景时完成的工作量。

为了进行测试,我将相机设置为完全相同的位置和方向,同时启用和不启用优化,并查看每个优化的 radeontop 统计数据。

之前:

  • 图形管道:~68-74%
  • 着色器时钟:~46.5%

之后:

  • 图形管道:~65-70%
  • 着色器时钟:37.5%

根据你在场景中测试的位置,结果会有所不同 - 有时高有时低。这取决于被剔除的片段数量,这是特定视图中被遮挡几何体数量的一个因素。在场景中的某些点,性能实际上略差(约 3-5%)。我发现这种情况只发生在几乎没有或没有遮挡几何体的区域,因此优化没有带来任何好处,同时仍然需要支付增加深度通道的开销。

尽管这些结果无疑是粗糙和不科学的,但这是不可否认的改进。另一个甚至可以与性能提升本身相媲美的好处是,在场景中移动时 GPU 利用率的变化显著减少。

如果没有优化,时钟速率变化范围约为 10%,图形管道利用率变化范围更大。在应用手动深度片段剔除后,我找不到任何着色器时钟偏离 37 超过百分之一或两的点。

这确实很有价值,因为帧率下降有时比持续略低的 FPS 更明显和烦人。这也让我希望,随着我继续将场景变得更大、更复杂,这种优化将会扩展。从技术上讲,这是有道理的;使用这种优化应该将片段着色器的调用次数限制为屏幕上像素数的大致数量,无论渲染的场景有多大,这个数量都是恒定的。

6、结束语

总之,我对这次实验的结果非常满意!没有真正缺点的免费性能总能让我心情愉快。我会在继续开发时关注性能,看看它如何扩展,但是是的,我已经对这个结果感到满意了。

我特别喜欢优化本身的简单性。与完全遮挡剔除等侵入式复杂解决方案不同,这种混合方法可以完全使用着色器实现,并且代码量不到 100 行(当然不包括后处理依赖项)。


原文链接:Speeding Up Three.JS with Depth-Based Fragment Culling

BimAnt翻译整理,转载请标明出处