欢迎光临
我们一直在努力

资源不是对象:为什么大型引擎最终都会走向 Handle

资源不是对象:为什么大型引擎最终都会走向 Handle

很多程序员最开始设计资源系统时,都会采用类似的写法:

Texture* texture;
Mesh* mesh;
Material* material;

这本质上是资源即对象的思想。

事实上,在项目早期,这种设计通常运行得很好。通常,在一个项目刚刚做起来的时候,这种编写清晰而简单的设计的感受让人愉悦,可问题出现在项目膨胀之后。

指针看起来简单

Texture* texture = load_texture("wall.png");

renderer.draw(texture);

访问成本很低,并且逻辑直观,不需要额外查表、资源管理器、间接层,也就是说,查询一个资源就是资源本身的事情,不需要耦合一大堆 Context、Manager。

这个做法在早期非常好用,甚至用不上 C++,C 语言就可以实现;性能上,想要更快只能把整个资源放到栈上,或者手动写出比编译器更优秀的汇编代码,当然,这是几乎不可能的。

第一个问题:热重载

假设纹理允许被热更新,系统检测到文件变化。
你可能会想,直接改即可。

问题来了,如果直接改,我们需要处理访问冲突问题:同一个资源是很有可能被不同线程使用的。我们很容易想到:用读写锁,理论可行,但新的问题出现了:一些资源访问周期可能非常长,比如纹理操作,无论是上传资源、解码文件,都是重大耗时工作,如果我们在热路径上需要等待可能漫长的解锁,代码的效率会变得极其低下,接近于甚至是不如串行。

于是,我们需要采取RCU的思想,在修改资源的时候,不立即销毁旧资源,而是另行创建一份,让每一次不可分割事务的texture访问到最新的资源,而在确认资源不被引用后安全销毁。

看起来就像这样:

texture = reload_texture();

第二个问题:Streaming

现代引擎不会让所有资源永久驻留内存。

例如:

玩家在城市,森林资源不需要加载。

进入森林后:城市资源卸载,森林资源加载。

此时资源地址可能发生变化,而指针天然绑定具体地址,地址变化意味着所有引用失效。

我们的指针炸了,你可能会想到这么写:

struct TextureImplement {
TextureMetadata metadata;
FileHandle fileHandle; // nullable
HostMemoryHandle hostMemoryHandle; // nullable
DeviceMemoryHandle deviceMemoryHandle; // nullable
}

typedef TextureImplement* Texture;

非常好,你发明了 Vulkan 式 Handle,不过实际写法会更复杂。

第三个问题:GPU 资源重建

对于 Vulkan 或 D3D12 等具体 Backend。

资源并不总是永久存在。

例如:

  • Swapchain 重建
  • Device Lost
  • 显存迁移
  • Streaming Upload

这些操作都可能导致底层资源被重新创建。

从系统角度看,这是同一个纹理;从内存角度看,这是新的对象;指针无法表达这种关系。

所以,我们在上一个问题的代码当中添加了 nullable 标签。

第四个问题:资源生命周期不属于调用者

这是最关键的问题。

对于普通对象:

Foo foo;

创建者负责销毁,生命周期非常明确,明确得可以跟 Rust 坐一桌。

而资源通常不同。例如: Texture 可能同时被:

  • Renderer
  • Material
  • Scene
  • Streaming System

引用。

所以我们需要一个 Table 统一管理这些 Texture 。

既然有 Table 了,要指针干什么呢?有下标就够了,不需要指针,而且从 Table 绕一层,方便管理。

你重新发明了 Handle。

此时资源已经脱离对象模型,而成为了系统状态。

Handle 的出现

于是很多大型系统开始采用另一种方式:

TextureID texture;

Handle 不直接指向资源,而只负责标识资源。例如:

struct TextureHandle {
uint32_t index;
};

真正的资源存放在 ResourceTable 中,于是结构变成:

Handle -> Resource Table -> Texture

Handle 真正解决了什么

很多人把 Handle 的意义理解为指针,尤其是看了 Windows 和 Vulkan 的 API 之后,因为这两家的 Handle 的确是以指针实现的(这种做法大量存在,因为 Batch 化对于它们没有任何意义,其本身高适应性的需求也无法写出万用的 Table 模型),但是其实际原理远远不是 C++ 的 pointer to class 那么简单。

Windows 不公布源码,我们无从得知其实现,只能反汇编推断,但是我们可以获取 vulkan 的开源 driver。

Handle 的意义是引入一层间接性,这层间接性允许系统修改资源,而不影响外部引用。

例如,Texture A 被重新加载,只需要更新 Table[index] 即可。

所有 Handle 自动获得新资源,而无需修改调用方。

Generation Counter

新的问题又出现了。

假设:

Handle(index = 5)

对应 Texture A。

随后,Texture A 被销毁,资源槽位被复用,新的 Texture B 再次占用:

index = 5

此时旧 Handle 会错误指向新资源。这就是大名鼎鼎的 ABA 问题

ABA Problem

于是进一步演化:

struct TextureHandle {
uint32_t index;
uint32_t generation;
};

访问时验证:

handle.generation == table[index].generation

不一致直接判定失效。

最终形态

经过多年演化,许多大型引擎都会形成类似结构:

TextureID -> Resource Manager -> Resource Table -> Texture -> BackendAPI

用户永远不会直接接触底层资源。

资源可以:

  • 热重载
  • Streaming
  • 延迟销毁
  • 异步创建
  • GPU重建

而 Handle 始终保持稳定。

资源不是对象

很多工程师第一次接触 Handle 时都会觉得:为什么不直接传指针?因为他们仍然把资源看作对象。

而大型系统最终会发现:

  • 对象属于某个作用域
  • 资源属于整个系统
  • 对象强调拥有权
  • 资源强调身份

Handle 的本质并不是指针替代品,而是一种身份标识。

当资源开始拥有独立于对象的生命周期时, Handle 几乎会自然出现。

这也是为什么游戏引擎、数据库、浏览器甚至操作系统内核,都在不同程度上使用 Handle 体系。

因为对于大型系统而言:资源最重要的属性不是它在哪里,而是它是谁。

当然,访问一个对象还得同时传入 Manager 和 ID 的编码风格,还是需要时间适应的

赞(0)
未经允许不得转载:171主机测评 » 资源不是对象:为什么大型引擎最终都会走向 Handle
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址