编写一个无绑定GPU抽象层

Hacker News Top 工具

摘要

一位开发者分享了他们实现的无绑定GPU抽象层Loon GPU,它基于Vulkan 1.3和Metal 4,灵感来自Sebastian Aaltonen的《No Graphics API》博客文章。该库使用GPU指针、顶点拉取和无绑定纹理堆来简化现代图形API。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/14 07:14

# 编写一个无绑定 GPU 抽象层 来源:https://www.kevin-gibson.com/blog/writing-a-bindless-gpu-abstraction-layer/ Loon GPU 库的标志,一只普通潜鸟头部的线条画。(https://github.com/rkevingibson/loon_gpu) 早在 2025 年 12 月,Sebastian Aaltonen 发表了一篇题为“No Graphics API”(https://www.sebastianaaltonen.com/blog/no-graphics-api)的博客——它精彩地讲述了 GPU 硬件的发展历史,并给出了一种有观点的主张,说明我们如何能够在现代硬件上简化现代图形 API。和许多图形程序员一样,我读了 Sebastian 的博客文章,非常喜欢——我深受启发,并决定看看我能在现有平台 API 之上,今天能多大程度地接近他所描述的 API。答案是“相当接近”。结果就是我称之为 Loon GPU 的项目,我已经把它放在了 Github(https://github.com/rkevingibson/loon_gpu)上。虽然这个库还很粗糙,测试不足,肯定充满 Bug,但它已可用,我想尽早分享出来,万一其他人感兴趣呢。目前它有一个 Vulkan 1.3 和一个 Metal 4 后端,这里我想做一个高级别的浏览,看看 API 设计如何映射到这些后端。简而言之,API 如下所示: - 没有缓冲对象,而是到处使用 GPU 指针。 - 没有顶点缓冲。改用顶点拉取,这样简单得多。 - 纹理和采样器以无绑定方式处理,通过索引指向一个大型纹理堆对象。 - 没有显式的绑定组,而是将设备指针传递给着色器以提供数据。 ## 指针是第一等公民 在 API 中,你通过 `malloc` 获取 GPU 内存,并通过 `free` 释放它。与它映射的后端不同,这里没有明确的“缓冲”对象概念,只有内存。默认情况下,你获得的是持久映射到 CPU 并针对 CPU 写入优化的内存,但你可以请求 GPU 本地内存或针对 CPU 回读优化的映射内存。与原始博客中描述的 API 相比,一个变化是:该 API 返回 GPU 设备指针——这是所有 3 种内存类型映射到的唯一指针,因此调用 `malloc` 会给你一个设备指针,如果需要,你可以调用 `get_host_pointer()` 将其转换为 CPU 端指针。 当你调用 `draw()` 或 `dispatch()` 函数在 GPU 上执行工作时,你可以提供 GPU 指针来给着色器传递参数。在 Metal 上这很简单——API 支持将任意 GPU 指针绑定到参数表。在 Vulkan 上,我们通过常量推送将这些指针传递给着色器。 在内部,我们确实会创建缓冲对象。在 Vulkan 中,每次分配都会得到一个 `VkBuffer`,绑定到分配的整个内存范围。在 Metal 中,我们创建一个 `MTLHeap`,并创建一个覆盖整个范围的缓冲。在两个底层 API 中,我们有时需要从 GPU 指针映射回缓冲 + 偏移对,因此我们存储一个已分配 GPU 指针的排序列表,并在需要时进行二分查找。在我编写这个库时,一个新的 Vulkan 扩展 `VK_KHR_device_address_commands` 发布了,当它可用时,应该可以消除大部分这种映射的需要。总体目标是视分配为较重的操作,用户空间分配器应该建立在它们之上,而不是为每个所需的对象进行分配。这应该使查找数组保持较小,并在需要时理想情况下查找速度很快。 虽然看起来不会产生巨大差异,但 API 直接给你指针确实让 CPU 端代码感觉自然得多。你可以更轻松地创建复杂的数据结构,就像它们在 C 语言中作为对象一样,并且配合符合人体工学的环形缓冲,构造绘制参数变得微不足道。感觉它简化了许多 CPU 端代码以及 GPU 端代码。结合无绑定纹理,我们不必担心管线布局、绑定组,或 Vulkan 绑定模型的任何复杂性。 ## 着色器 这个包装器对着色器的外观有明确的主张,这也是它比原生 Vulkan 或 Metal 更容易使用的原因之一。其思路是所有 GPU 数据都是无绑定的,着色器通过传递给它们的单个指针获取输入。为此,我们需要一种着色语言。当我开始这个实验时,我决定使用 Slang。它支持指针,可以编译成 SPIRV 和 Metal,并且看起来适用于我的用例。而且确实大部分能用!但不幸的是,有一些复杂情况,我认为这是尝试使用 Loon 时最粗糙的部分。为了理解困难,让我们从我希望实现的目标开始。 在 Vulkan 中,我希望有一个包含单个指针的常量推送范围,分配给每个着色器阶段。在 Metal 中,我希望将单个缓冲绑定到每个阶段。在 Loon 中,目前看起来是这样: | 阶段 | Vulkan 常量推送范围(字节) | Metal 缓冲索引 | |------|---------------------------|----------------| | 计算 | [0, 8) | 0 | | 顶点 | [0, 8) | 0 | | 片段 | [8, 16) | 1 | 在语法方面,Slang 提供了一种非常好的方式将着色器参数映射到常量推送。你可以使用如下语法声明一个着色器阶段(例如顶点阶段):`[shader("vertex")] VertexStageOutput vertexMain(uniform InputData* args, uint32_t vertexIdx : SV_VertexID)`。Slang 会将 `uniform InputData*` 参数转换为常量推送。不幸的是,你无法控制这些常量推送的布局——所有常量推送都将获得从 0 开始的范围。这使得向顶点和片段着色器传递不同参数实际上不可能,因为 Vulkan 在一次绘制调用中为所有着色器共享常量推送范围。这在一个 Github issue(https://github.com/shader-slang/slang/issues/9643)中被跟踪,但对于 Slang 来说,在一般情况下解决这个问题并不是一个简单的问题。解决方法是改为在全局作用域中做类似这样的事情: ```slang struct Args { VertexInput* vert; FragmentInput* frag; }; [[vk::push_constant]] uniform Args args; ``` 但对于计算着色器,入口点参数语法效果很好。 不幸的是,Metal 的情况要糟糕得多。Slang 对 Metal 的支持是实验性的,我遇到了几个问题(https://github.com/shader-slang/slang/pull/10051)(https://github.com/shader-slang/slang/pull/10592),使得在 Vulkan 和 Metal 后端之间共享着色器变得困难甚至不可能(https://github.com/shader-slang/slang/issues/10675)。我希望未来这会有所改善,并且我已经提交了一些 PR 来解决我认为可以修复的问题,但与此同时,我一直在手动将我的 Slang 着色器翻译成 Metal,并在运行时在它们之间选择。这也不是对 Slang 的批评——Metal 明确不是主要目标,而且我们正以一种奇怪的方式使用它,这种方式不能作为通用的绑定解决方案。理想的解决方案可能是为这种绑定模型设计的自定义着色器编译器或转译器,但这目前不在我的范围内。自定义语言是一个诱人的兔子洞,但目前我宁愿花时间改进基础库。 另一个值得提及的痛点在 Metal 中计算线程组大小。在 Vulkan 和 DirectX 中,线程组大小由着色器注解确定,而 Metal 则在调度时设置(最大大小在管线创建时确定)。这使得跨平台 API 变得困难。Metal 4 让我看到了希望,因为添加了 `required_threads_per_threadgroup()` 注解——但目前无法从 CPU 端读取这个值。目前,loon 要求计算着色器上有这个注解,并进行一些非常 hacky 的解析来查找注解值,这样我们就可以将该值适当地传递给 API。丑陋,但能用。希望在未来版本中,这个注解值能通过管线状态对象暴露出来。随着 Mesa 的新 KosmicKrisp 驱动程序的改进,我可能也会减少对专用 Metal 后端的关注——目前翻译性能不佳,但 KK 项目仍处于非常早期的阶段,应该只会越来越好。 ## 绘制 这是一个小点,但与标准图形 API 有所不同——没有顶点缓冲。相反,在你的着色器中使用指向顶点数据的指针,并使用顶点索引获取所需内容。这消除了大量 API 表面。仍然使用索引缓冲,但同样通过 GPU 指针指定。 ## 无绑定纹理和采样器 在 Metal 中,无绑定纹理和采样器得到 API 的原生支持。从 Loon API 获得的 `TextureView` 和 `Sampler` 对象就是 Metal 暴露的 `GPUResourceID` 值,你可以将它们放入着色器数据结构中,并像预期那样使用它们。在 Vulkan 中,我们创建一个大的绑定集并使用描述符索引。下表解释了绑定布局,它基于 Slang 生成的内容。 | 资源类型 | 绑定槽位 | |----------|----------| | 采样器 | 0 | | 采样图像 | 2 | | 读/写图像 | 3 | 在 Slang 中,`Sampler` 和 `TextureView` 类型映射到 `DescriptorHandle`,这些句柄可用于从着色器访问资源,而无需担心绑定布局。 ## 无图像布局 图像布局和布局转换是 Vulkan 中最不喜欢的部分之一,我想避免需要它们。Metal 完全没有任何布局,因此如果 API 中包含它们,那纯粹是为了 Vulkan。更糟糕的是,由于它们在 Vulkan 中与屏障绑定,在我们的 API 中要求显式图像转换可能会在 Metal 后端引入不必要的管线停顿,损害性能。通过图像布局转换完成的有两个大致正交的事情: 1. 在队列时间线上进行初始化。 2. 转换到适用于不同用途的优化布局。 第 1 点是必需的——Vulkan 图像以 `VK_IMAGE_LAYOUT_UNDEFINED` 状态创建,需要先转换到另一个布局才能以任何方式使用。我们通过维护一个全局未初始化纹理列表来解决这个问题,并在下一次命令缓冲提交时,记录一个小的命令缓冲,将所有当前未初始化的图像转换到 `VK_IMAGE_LAYOUT_GENERAL`。对于单个队列来说这没问题,但在多队列场景中(例如用于异步计算),这实质上引入了它们之间的一个同步点——没有队列可以开始任何工作,直到这个图像初始化命令缓冲完成,因为无绑定特性意味着我们不知道纹理何时被使用。 对于转换到优化布局,我们基本上忽略它。Vulkan 最近引入了 `VK_KHR_unified_image_layouts`(https://www.khronos.org/blog/so-long-image-layouts-simplifying-vulkan-synchronisation),现代 NVidia 显卡支持它,表明到处使用通用布局不应该降低性能。不幸的是,其他厂商至少在 Windows 上似乎不支持这个扩展。历史上,使用特定图像布局的原因是为了启用各种优化,比如 AMD 的 Delta Color Compression(DCC)。虽然关于这些特性的大量硬件细节并不公开(或者对我来说太难找到),但我确实查看了 Mesa 驱动源代码树,从粗略阅读来看,在现代 AMD 芯片(RDNA3 及以上)上,除了多重采样纹理外,图像布局并不重要。因此,虽然这意味着可能放弃一些性能,但我很乐意进行这种权衡以完全消除那个 API 表面。希望将来有更多驱动程序支持 `VK_KHR_unified_image_layouts`,我们就能确信通用布局在任何地方都很好用。 ## 无法完成的事情 当我开始这个项目时,我的第一步实际上是将 Sebastian 博客中的 API 复制粘贴到我的文本编辑器中。尽管我在实现过程中改变了一些东西,但只有少数几件事我根本无法在当前底层 API 上实现。 - 博客中有一个 `gpuSetBlendState()` 函数,将混合状态从管线中解耦出来。在 Vulkan 上,`VK_EXT_extended_dynamic_state3` 可以实现这一点,但 Metal 目前不支持(因此通过 MoltenVK 或 KosmicKrisp,`VK_EXT_extended_dynamic_state3` 也不支持 Apple 设备)。目前,我将混合状态保留为管线创建的一部分。 - 原始设计中的拆分屏障使用 `gpuSignalAfter()/gpuWaitBefore()`,并在内存位置等待一个值——类似于 futex API。不幸的是,现有 API 中没有任何类似的东西,也没有简单的模拟方式。我确实想提出一个简化的拆分屏障 API,但它看起来会相当不同。 - 纹理堆是不透明对象。原始博客将它们视为内存中的指针,使用 `VK_EXT_descriptor_heap` 扩展在技术上可以实现。我暂时将它们保留为不透明对象,因为那个扩展仍然非常新,并且在 Metal 上没有真正的等价物。 ## 下一步 在我认为这个库可以称为 1.0 版本之前,我还有很多功能想要支持。虽然我总体上对 API 的形状和人体工程学感到满意,但随着我使用它编写更多示例,我预计它会有变化——现在不要指望稳定性!现在我正在研究可调试性,增加对调试组、GPU 捕获和更好错误处理的支持——对于无绑定方法来说,这变得更加重要,因为驱动程序无法提供那么多帮助。我还想扩展示例,以获得对现有功能的更好测试覆盖,我预计有很多 Bug 隐藏在显眼的地方。在后续规划中,添加对网格着色器和硬件光线追踪的支持在我的列表上,但在核心变得更加稳定之前,它们不是高优先级。 在实现方面,增加对 `VK_EXT_descriptor_heap` 和 `VK_KHR_device_address_commands` 的支持可以提高现代设备上的性能。在撰写本文时,我正处于工作间隙,因为我决定离开西雅图,与家人一起搬回加拿大。因此,我预计在接下来的短时间内我会花相当多的时间在这个库上——失业让我有比正常更多的空闲时间。如果您正在寻找一位具有计算机图形学、高斯泼溅或计算机视觉经验的 C++ 开发人员,请与我联系。我主要会在多伦多地区寻找工作,但我也非常接受远程工作。 如果您对 Loon 感兴趣或有任何反馈,请在 BlueSky(https://bsky.app/profile/kevin-gibson.com)或 Mastodon(https://mastodon.gamedev.place/@kevingibson)上联系我。 返回文章列表(https://www.kevin-gibson.com/blog)

相似文章

用500行纯C++实现软件渲染

Hacker News Top

一个教程系列,通过用500行C++从零构建软件渲染器(无需外部库),演示OpenGL、Vulkan、Metal和DirectX的工作原理。

我为 Emacs 构建了一个 GPU 后端

Hacker News Top

作者描述了如何在 macOS 上使用 Metal、在 Linux 上使用 OpenGL 为 Emacs 构建基于 GPU 的显示后端,从而提升渲染性能并启用视频播放和动画光标等新效果,且无需修改核心重新显示引擎。