面向数据的设计探险.第1部分:网格数据

英文原文:
https://blog.molecular-matters.com/2011/11/03/adventures-in-data-oriented-design-part-1-mesh-data-3/

  让我们面对现实吧,现代处理器(无论是个人电脑、游戏机还是手机)的性能主要取决于内存访问模式。尽管如此,面向数据的设计被认为是新奇的东西,只会慢慢进入程序员的大脑,这一点确实需要改变。让同事修复您的代码并提高其性能从一开始就不能成为编写糟糕代码的借口(从性能的角度来看)。

  这篇文章是正在进行的关于分子引擎中的某些事情是如何以面向数据的方式完成的,同时仍然使用OOP概念的系列文章中的第一篇。关于面向数据的设计,一个常见的误解是它是“类似C的”,而不是“面向对象的”,因此不太容易维护–但事实并非如此。我们今天要看的具体示例是如何组织网格数据,但让我们先从前提条件开始。

面向数据的设计
  关于面向数据的设计,人们已经说了很多,写了很多,网上也有一些不错的资源。Mike Acton(失眠游戏的技术总监)是一位著名的面向数据设计的倡导者,他在网上也有一些有趣的幻灯片

  一般说来,我不会详细介绍面向数据的设计,所以让我快速总结一下面向数据的设计对我来说是什么:

  1. 先考虑数据,然后再考虑代码。类层次结构并不重要,但数据访问模式很重要。
  2. 想一想游戏中的数据是如何访问的,它是如何转换的,以及你最终会对它做什么,例如粒子、蒙皮角色、刚体和其他大量的例子。
  3. 当有一个的时候,就会有很多。在数据流中思考。
  4. 注意虚函数、指向函数的指针和指向成员函数的指针的开销。

  为了让每个人都意识到第1点和第2点的重要性,请考虑一个非常简单的示例:

char* data = pointerToSomeData;
unsigned int sum = 0;
for (unsigned int i=0; i<1000000; ++i, ++data)
{
  sum += *data;
}

  在上面的例子中,我们取一百万字节的总和,仅此而已。这里没有花哨的花招,没有隐藏的C++开销。在我的PC上,这个循环大约需要0.7毫秒。

  让我们稍微改变一下循环,并再次测量它的性能:

char* data = pointerToSomeData;
unsigned int sum = 0;
for (unsigned int i=0; i<1000000; ++i, data += 16)
{
  sum += *data;
}

  唯一改变的是,我们现在对第16个元素求和,也就是将++data改为data+=16。请注意,我们仍然只取100万个元素的和,只是这次元素不同!
  这个循环花了多少时间?5毫秒。让我为您解释清楚:这个循环比原来慢7倍,即使我们访问的元素数量相同。性能完全取决于内存访问,以及如何很好地利用处理器的缓存。

  记住第三点,“只有一个,就会有很多个。在数据流中思考。“?这是什么意思?这很简单:如果你在游戏中只有一个纹理,网格,声音样本,刚体等等,你就会有很多这样的东西。以一种可以一次处理多个对象的方式编写代码–从对象流的角度考虑。这并不意味着您需要删除网格和纹理类,并将它们分别转换为MeshList和TextureList。这将在我们后面的示例中处理。

  最后但并非最不重要的一点是:“注意虚函数、指向函数的指针和指向成员函数的指针的开销。”是一件非常贴近我内心的事情。这是经验较少的程序员经常忽略(或忘记)的事情,我总是确保让我的学生了解虚函数调用等的潜在成本。尽管如此,它们迟早会在内部循环中使用,并会扼杀您的性能。

  直截了当地说,永远不应该在每个对象的基础上使用虚拟函数、指向函数的指针或指向成员的指针函数-不是针对网格的每个基本体组,不是针对粒子系统中的每个粒子,也不是针对纹理中的每个纹理元素;您明白了这一点。它们很可能会导致指令和数据高速缓存未命中,并扼杀您的性能-请注意这一点!

以面向数据的方式处理静态网格数据

  话虽如此,让我们从今天的示例开始,说明如何以面向数据的方式处理静态网格数据。当然有很多方法可以组织数据,所以我们感兴趣的是完全面向对象的设计和更关注数据访问模式的设计之间的性能差异。

我们示例的场景如下:

  • 我们希望渲染500个静态网格,并对它们执行视锥剔除。
  • 每个网格平均有一个顶点缓冲区、索引缓冲区和大约3个子网格。
  • 每个子网格存储起始索引和其所属网格的顶点缓冲区/索引缓冲区中使用的索引数量。此外,每个子网格都有一个材质和一个轴对齐的边界框。
  • 材质包含漫反射纹理和光照贴图。

当然,没有什么非常复杂的事情,这使得我们更容易看出这两种方法之间的区别。

照本宣科的面向对象方法

让我们从面向对象方法的类设计开始:

class ICullable
{
public:
  virtual bool Cull(Frustum* frustum) = 0;
};
 
class SubMesh : public ICullable
{
public:
  virtual bool Cull(Frustum* frustum)
  {
    m_isVisible = frustum->IsVisible(m_boundingBox);
  }
 
  void Render(void)
  {
    if (m_isVisible)
    {
      m_material->Bind();
      context->Draw(m_startIndex, m_numIndices);
    }
  }
 
private:
  unsigned int m_startIndex;
  unsigned int m_numIndices;
  Material* m_material;
  AABB m_boundingBox;
  bool m_isVisible;
};
 
class IRenderable
{
public:
  virtual void Render(void) = 0;
};
 
class Mesh : public IRenderable
{
public:
  virtual void Render(void)
  {
    context->Bind(m_vertexBuffer);
    context->Bind(m_indexBuffer);
 
    for (size_t i=0; i<m_subMeshes.size(); ++i)
    {
      m_subMeshes[i]->Render();
    }
  }
 
private:
  VertexBuffer* m_vertexBuffer;
  IndexBuffer* m_indexBuffer;
  std::vector<SubMesh*> m_subMeshes;
};

  希望你们中的一些人看到这样的设计已经哭了。有人可能会认为以上是对疯狂的OOP设计的夸大,但我向你保证,我在发行的游戏中见过更糟糕的情况。最糟糕的例子的竞争者大致如下:

class GameObject : public IPositionable, public IRenderable, public IScriptable, ...

  我记不清确切的细节,但实际实现继承自6或7个类,其中一些类有实现,其他类只有纯虚函数,其他类只有虚函数,其唯一目的是使Dynamic_Cast在它们上工作…。我不想指手画脚,而是要重申,这样的设计可以在出厂的商业游戏中找到,而且不是虚构的例子-但我们不要离题。

  回到最初的例子,它有什么不好的呢?嗯,有几件事:

  1. 两个接口IRenderable和ICullable分别在每次调用Render()和cull()时导致虚拟函数调用命中。此外,虚拟函数几乎永远不能内联,这进一步降低了性能。
  2. 剔除网格时内存访问模式错误。为了读取AABB,整个高速缓存线(在现代体系结构中为64字节)将被读取到处理器的高速缓存中,无论您是否需要它。归根结底,这意味着访问不必要的数据,这不利于性能,从我们的第一个简单示例(总和为100万字节)可以看出这一点。
  3. 渲染子网格时内存访问模式错误。每个子网格都会检查其自己的m_isVisible标志,这将再次将更多数据拉入缓存,而不是必需的。与上例相同的不良行为。
  4. 一般情况下,错误的存储器访问模式。上面的点3和4假设所有网格和子网格的数据都线性地布置在存储器中。在实践中,很少会出现这种情况,因为程序员通常只需要在其中添加一个新的/删除的内容,然后就可以完成它了。
  5. 未来算法的内存访问模式不佳。例如,为了将几何体渲染到阴影贴图中,不需要绑定材质,只需要开始索引和索引数量。但每次我们访问它们时,我们也会将其他数据拉到缓存中。
  6. 可线程性差。您将如何在不同的CPU上执行多个子网格的剔除过程?您将如何对Spus执行淘汰?

  那么,如何才能改善这种情况呢?我们如何组织我们的数据,使其在现在和未来都能提供良好的性能?

面向数据的设计方法

  最明显要去掉的是这两个接口,以及与它们相关联的虚拟函数调用。相信我,你不需要这些接口。

  我们可以更改的另一件事是确保需要一起访问的数据在内存中实际上是彼此相邻分配的。此外,如果这是以某种方式隐式完成的,而程序员不必太担心内存分配,那就更好了。

沿着DOD的路线走下去,这样的设计可能是这样的:

struct SubMesh
{
  unsigned int startVertex;
  unsigned int numIndices;
};
 
class Frustum
{
public:
  void Cull(const float* in_aabbs, unsigned bool* out_visible, unsigned int numAABBs)
  {
    // for each AABB in in_aabbs:
    // - determine visibility
    // - store visibility in out_visible
  }
};
 
class Mesh
{
public:
  void Cull(Frustum* frustum)
  {
    frustum->Cull(&m_boundingBoxes[0], &m_visibility[0], m_boundingBoxes.size());
  }
 
  void Render(void)
  {
    context->Bind(m_vertexBuffer);
    context->Bind(m_indexBuffer);
 
    for (size_t i=0; i<m_visibleSubMeshes.size(); ++i)
    {
      if (m_visibility[i])
      {
        m_materials[i]->Bind();
        const SubMesh& sm = m_subMeshes[i];
        context->Draw(sm.startIndex, sm.numIndices);
      }
    }
  }
 
private:
  VertexBuffer* m_vertexBuffer;
  IndexBuffer* m_indexBuffer;
 
  std::vector<float> m_boundingBoxes;
  std::vector<Material*> m_materials;
  std::vector<SubMesh> m_subMeshes;
  std::vector<bool> m_visibility;
};

让我们来研究一下这种方法的特点:

  1. 剔除子网格时良好的内存访问模式。为了剔除N个元素,需要在内存中恰好访问N*6个浮点数。可见性被写入到内存中的单独目的地,因此那里也没有开销。
  2. 渲染子网格时良好的内存访问模式。所有需要的数据都连续存储在内存中,我们只将实际需要的数据加载到缓存中。
  3. 良好的内存访问模式,以备将来使用。如果我们想要将子网格渲染到阴影贴图中,我们只需要访问m_subMesesh,仅此而已。
  4. 一般来说,良好的内存访问模式。为了使元素是连续的,不需要手动分配存储器。Std::Vector总是如此,简单数组也是如此。如果需要,还可以进一步将所有网格实例放入单独的容器中。
  5. 更轻松的线程性。如果剔除(或任何其他操作)需要跨多个线程(或SPU)拆分,我们可以将数据分成块,并将每个数据块提供给不同的线程。在这些情况下不需要同步、互斥或类似的东西,并且该解决方案几乎可以完美地扩展,从而可以轻松地利用4核、8核或N核处理器,这将是PC和控制台的未来。

  我并不认为提出的解决方案是完全完美的–当然不是,它只是一个例子。从我的头顶上看有哪些改进之处:

  1. 我们可以直接存储子网格的数据,而不是将布尔存储到out_visible数组中,从而节省了在Mesh::Render()中渲染可见网格时的分支和进一步的间接性。
  2. 更进一步,如果我们愿意,我们可以直接将渲染命令存储到GPU缓冲区中,例如,通过写入PS3上的命令缓冲区。
  3. 如果您的内容管道支持,请将所有静态数据烘焙到一个大网格中。然后保证整个级别的所有静态网格数据在内存中是连续的,因此您可以自动获得上述解决方案的好处。

  我们可以从这样的设计更改中看到什么性能改进?在我进行的实验中(500个网格,每个3个子网格,不包括Direct3D11API调用开销),两种解决方案之间的差异约为1.5ms。
  如果这听起来不算多,那么考虑一下1.5ms实际上几乎占60 Hz帧的10%。此外,我们在这里讨论的是500个网格,并且只涉及静态网格数据的设计。如果在整个引擎中应用面向数据的设计,性能优势可能是巨大的。
  如果你想知道,面向数据的设计当然赢了。让我们在结束这篇文章的时候说,你现在需要开始关心你的数据访问模式了,我是认真的。斯科特·迈耶斯也是。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值