面向数据设计的冒险之旅 - 第3部分A:所有权

发表于2018-03-01
评论1 4.7k浏览


译者: 崔嘉艺(milan21)   审校:王磊(未来的未来)


在Molecule引擎的开发过程中我注意到的一件事情是,定义明确的数据所有权可以极大地帮助遵循面向数据的设计方法,反之亦然。


首先确定数据的所有权会要求人们更多地考虑谁拥有这IE数据,谁来创建和销毁实例,但是相应的在维护,性能和可调试性方面完全可以获得回报。 我想回到我最喜欢的例子之一来说明这个问题,因为这个例子很容易被大家理解:渲染一堆静态网格物体。


网格渲染


我们正在看的例子如下:

l  一个关卡包含了任意数量的静态网格。一个静态网格由一个顶点缓冲区,一个索引缓冲区和一组三角形组成,这些三角形组描述了网格中每个组/子网格所使用的索引。 我们调用一个struct / class来把这个信息保存为一个Mesh。

l  这些网格中的每一个都可以使用不同的着色器和材质进行渲染,因此这些网格其实不是网格的一部分,而是被称为MeshInstance,MeshComponent或类似的东西。 我们需要这个区别,因为同一个网格可以在不同的着色器和材质中实例化几次。


如果你不关心内存、性能、保持同事的健康的话,那么最简单的解决方案是使用new来分别维每个Mesh分配内存,并在MeshInstance中存储一个指向Mesh的指针。 因为几个这样的实例现在可以引用同一个Mesh,所以你可以持有一个shared_ptr <Mesh>或类似的东西,或者建立一些其他的引用计数方法到Mesh类中,并存储一个普通的原始指针。 而当你这么做的时候,顶点缓冲区和索引缓冲区也被引用计数,因为你可以这么做,以及这种做法很棒,也很符合面向对象编程类型。 那么问题解决了。

那么,是这样么?不是。

使用这种方法有这么几个主要的缺点:

l  所有权:谁拥有Mesh实例?每当MeshInstance被删除或是超出范围的时候,它只是递减引用对象的引用计数。有人可以告诉说什么时候实际的对象被摧毁么?顶点缓冲区和索引缓冲区也是如此。顶点缓冲区和索引缓冲区被删除的精确时间是什么时候?

l  性能:你将原始指针,shared_ptr <>或任何沿着这个思路的内容分发给你的类的用户。这使得在内存中移动东西非常困难(如果不是不可能的话),并且你需要保证让所有的指针都指向正确的对象。因此,大部分对象都散布在整个堆中,访问它们的时候会导致大量的缓存未命中,因为子系统无法在内存中重新排列对象,把这些对象放在它们最合适的位置。

l  可调试性:原始指针只是会尖叫“悬空指针”。哦,你正确地释放了一个Mesh引用,原来的对象被摧毁了,但是你仍然坚持使用你的MeshInstance?那么,内存管理器同时在内存中的同一个地点分配一个新的实例,而你现在正在处理陈旧的数据而不知道。我猜这不是你的幸运日。


当然,我们可以做得更好。

一个更好的解决方案

我们首先要考虑的是:谁要使用这个数据,谁拥有,创建和销毁这个数据?让我们首先来看看顶点缓冲区和索引缓冲区。


在渲染引擎中唯一应该进行的API调用是渲染后端。按照Molecule的说法,渲染后端只是一个拥有大量免费函数的命名空间,可以直接与D3D11OpenGLDX9或其他API进行交流。移植图形模块意味着移植后端,并额外暴露一些特定于平台的函数,但是这是关于渲染后端的。

渲染后端也负责根据64位密钥对绘制调用进行排队和排序,因此绑定顶点缓冲区,设置渲染状态,执行实际绘制调用等等是引擎唯一要做的事情。

因为渲染后端是唯一触及数据的东西,所以如果顶点缓冲区和索引缓冲区等所有底层渲染相关的数据在内存中尽可能接近的话,那么它将受益最大。因此,渲染后端本身也应该负责创建/销毁这些缓冲区,并对这些缓冲区负责。

另外,我们不想把原始指针返回给我们的内部,因为我们希望能够跟踪对陈旧数据的访问 - 没有更多的悬空指针!而且,通过给用户一个简单的标识符,比如一个整数而不是一个指针,诸如“我是否拥有这个数据?我需要释放这个数据吗?“等问题实际上从来没有出现过。

简单胜过一切

那么在记忆中尽可能接近最简单解决方案的东西是什么?是数组。

这是Molecule引擎使用的解决方案。渲染后端只保存了4096个顶点和4096个索引缓冲区的数组。当然这些数字是可配置的,但是你是否曾经需要超过四千个顶点缓冲区同时在运行?如果有这种情况的话,那么在某个特定的框架中,会出现至少有4k个不同的绘制调用同时出现的最坏情况,无论如何(就性能而言),这都是不合理的。

现在,你可以简单地返回一个16位整数,用于唯一标识数组中的顶点缓冲区和索引缓冲区,而不是指向数组的索引。不仅是所有权问题不再是一个问题(你不能删除或释放一个16位整数,而不是减少它的引用计数),你也可以建立一个机制来跟踪一个给定的整数是否指向一个现有的对象- 这就是通常所说的句柄。根据实例的最大数量,一个16位的int可能就足够了,或者你总是可以转到使用32位整数。

也就是说,在Molecule引擎中创建和销毁顶点缓冲区和索引缓冲区的接口如下所示:


namespace backend
{
  VertexBufferHandle CreateVertexBuffer(unsigned int vertexCount, unsigned int stride, const void* initialData);
  VertexBufferHandle CreateDynamicVertexBuffer(unsigned int vertexCount, unsigned int stride);
  void DestroyVertexBuffer(VertexBufferHandle handle);
  IndexBufferHandle CreateIndexBuffer(unsigned int indexCount, IndexBuffer::Format::Enum format, const void* initialData);
  void DestroyIndexBuffer(IndexBufferHandle handle);
}


对网格数据进行引用

考虑到网格数据的所有权,我们可以想出一个类似的简单解决方案来引用/存储这些数据。

Molecule引擎中,一个叫做渲染世界的东西包含了与图形模块相关的所有数据,主要的内容是从资源包提取的东西,比如像是网格,骨架,动画,粒子系统,图形相关组件等等

类似于由渲染后端保持的固定大小的顶点缓冲区和索引缓冲区,渲染世界存诸如包含在资源包中的所有网格的数组。 因为所有其他的渲染相关的数据也被渲染世界所拥有,我们也可以通过使用句柄来引用它。

这意味着Mesh现在看起来像是这样:

struct Mesh
{
  VertexBufferHandle m_vertexBuffer;
  IndexBufferHandle m_indexBuffer;
  TriangleGroupHandle m_triangleGroups;
  uint16_t m_triangleGroupCount;
};

没有引用计数,没有shared_ptr <>,没有原始指针。 Mesh是可复制的,可以通过使用memcpy()在内存中移动。 你如何保证只有一个网格? MeshInstance是什么样子的?

很简单:只需复制网格即可。 你只需要复制一个Mesh即可。 MeshComponent只存储一个Mesh的副本,以及着色器,材质等句柄。

实际上,MeshComponents本身是由渲染系统负责渲染的,但这是另一篇文章的内容了。

 

结论

让我们快速回顾一下这篇文章的内容:

顶点缓冲区和索引缓冲区由渲染后端拥有。没有原始指针分发,只有句柄。句柄是一个不透明的数据类型,用户不应该(也不会)知道如何解释给定的整数。

l  网格实例属于渲染世界。网格简单地通过复制来引用,因为这会为你提供所需的所有数据,以便对其进行处理。

l  没有引用计数机制,没有原始的指针,最重要的是没有悬空指针。系统自动识别出对陈旧数据的访问。另外,大多数句柄占用的内存比指针少,特别是在64位系统上。

l  Mesh实例,MeshComponents和许多其他组件都只是数据容器而已,因此可以在内存中自由移动,而不必担心所有权,构造/删除等问题。

在接下来的文章中,我们将仔细研究Molecule用于引用数据的内容,这些数据由负责更新/渲染的子系统在内存中进行移动。 这样一个系统是负责渲染网格的系统,在这种系统中,以缓存友好的方式访问数据是至关重要的。 具体来说,我们将详细介绍内部引用(=句柄)和外部引用(= ID)。

 

【版权声明】

原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引