我今天学到了关于GPU的知识
摘要
一位游戏开发者讲述了他在游戏《Blackshift》中修复GPU渲染bug的经历。问题是将8位邻接整数转换为浮点数时出现的浮点数精度问题,导致在部分NVIDIA GPU上出现视觉瑕疵,且该bug只在主渲染模式中出现,预览模式中并未出现。
<p><a href="https://lobste.rs/s/rrelxd/i_learned_something_about_gpus_today">评论</a></p>
查看缓存全文
缓存时间: 2026/05/09 00:34
# 今天学到了关于GPU的一些知识
来源:https://foon.uk/blackshift-sand-bug/
2026年4月13日
今天学到了关于GPU的一些知识。
我刚发布了Blackshift(https://store.steampowered.com/app/741110/Blackshift/)的更新,其中新增了这些沙块:
[](https://foon.uk/blackshift-sand-bug/sand.jpg)一切正常,直到开始收到这样的截图反馈:
[](https://foon.uk/blackshift-sand-bug/glitch.jpg)告别了我的夜晚,我开始思考这是怎么回事。
每个沙块使用相同的模型:一个细分平面。顶点着色器移动这个平面的顶点,使其表面变得凹凸不平,片段着色器则添加沙块与其他块1(https://foon.uk/blackshift-sand-bug/#note1)交界处的阴影。
[](https://foon.uk/blackshift-sand-bug/model.png)单个沙块,在顶点着色器处理之前的样子的。2(https://foon.uk/blackshift-sand-bug/#note2)因此它知道在哪里放置阴影,片段着色器读取一个邻接图。这是每个块的8位整数,每位对应一个相邻方向。
对于这个块,着色器接收到值238,并在其南边缘和东北角绘制阴影。和Blackshift中的所有东西一样,沙块使用GPU实例化绘制:屏幕上所有沙块在同一批次中一起绘制。GPU为每个实例接收一个变换矩阵,并知道对所有实例使用相同的网格。由于每个实例有不同的邻接数据,邻接值也作为每实例数据3(https://foon.uk/blackshift-sand-bug/#note3)随变换矩阵一起发送。
邻接值是一个8位整数,但由于bgfx只支持浮点数作为实例数据,这个整数在写入实例缓冲区之前被转换为浮点数。4(https://foon.uk/blackshift-sand-bug/#note4)顶点着色器读取它并传递给片段着色器,片段着色器再将其转换回整数,并检查各个位以确定在哪里绘制阴影。
当然,使用浮点数时必须小心精度问题,但它们的精度足以存储0到255之间的任何整数,所以没有问题。如果CPU决定邻接整数是238,它会写入238.0f,着色器会读取238.0f,转换回238并读取位。这就是沙块的渲染方式。那么,bug在哪里?
* * *
我的第一反应是这看起来像Z-fighting,但这说不通。沙块表面是单一表面;没有什么可与之对抗的。我关闭了z-buffer来检查,没错,伪影仍然存在。这不是Z-fighting。
其次,我检查了Level Pit预览。在Level Pit GUI中,Blackshift渲染人们上传的关卡预览,你可以在决定玩哪个之前查看这些预览。这些预览的渲染方式与普通游戏帧有些不同,所以它们有时可以成为很好的测试用例;如果bug出现在普通渲染中但不在预览中,反之亦然,那么不同的渲染技术可以暗示可能出了什么问题。
[](https://foon.uk/blackshift-sand-bug/pit.jpg)Level Pit预览所以我询问了受影响的玩家,在这种情况下,bug没有出现在预览中,只出现在游戏中。
这是一个很好的线索。预览和主渲染之间的最大区别是预览渲染根本不使用GPU实例化;它是关闭的。几乎每个对象每个材质一个绘制调用,所有通常作为实例数据发送的内容都改为作为uniform发送。
所以我花了很长时间仔细检查我的实例化代码,但最终,这完全是个红鲱鱼。我在主渲染中也关闭了实例化,bug仍然存在。那就意味着......当然。只剩下一件其他可能的事了。毕竟,预览渲染和真实渲染之间真的只有一个其他区别。
回想一下那些浮点数。CPU决定邻接整数是238,它将238.0f写入实例数据缓冲区,顶点着色器将其取出并写入一个varying,片段着色器从那里读取它,解释它并绘制阴影。
问题是,当238.0f从顶点着色器传递到片段着色器时,像任何varying变量一样,它会被GPU在正在绘制的三角形区域上进行插值。因为三个角的值都相同,我以为结果插值在三角形上每个点也应该相同。确实如此......在我的机器上。
但当GPU进行这种插值时,它们是以透视正确的方式做的,这涉及除以每个片段的深度,然后再乘以5(https://www.soundingapixel.com/lessons/3d-basic-rendering/rasterization-practical-implementation/perspective-correct-interpolation-vertex-attributes.html),并且——我知道这有点模糊——这可能会导致数值精度偏差。我想我的GPU足够聪明注意到三角形的三个点具有相同的值,并跳过所有计算,但这些玩家的GPU没有这样做。它们进行了所有那些透视除法和乘法,并得到那些非常略微偏差的浮点数。
这解释了所看到的伪影;每当一个像素的邻接值通过插值(即使 infinitesmially 低于它的应该值),它就会被解释为下一个整数,这代表一个完全不同的邻接图,所以是不同的阴影图案。
最终,我的修复是在CPU端的;不是写入
`(float) adjacency`到实例缓冲区,而是让它写入
`(float) adjacency + 0.5f`现在任何抖动都安全地落在同一个整数内,伪影消失了。6(https://foon.uk/blackshift-sand-bug/#note6)
那么,为什么伪影没有出现在预览渲染中?好吧,再看一下。你看到了吗?
[](https://foon.uk/blackshift-sand-bug/pit.jpg)答案:预览是用正交相机渲染的。不需要透视校正。
## 结论
这里的要点是:在GPU世界中,向三角形的三个顶点写入相同的值并不能保证三角形中的所有片段都会获得该确切值。某些片段最终可能获得略微偏差的值,但只在某些硬件上,只在使用透视投影时,尽管也可能有硬件即使不使用透视投影也会做同样的事情。
## 链接
[How I still use Flash](https://foon.uk/how-flash-2022)(我仍然如何使用Flash),关于我制作的另一个游戏
[Fixing Quicklook](https://foon.uk/fixing-quicklook),关于处理Tim Cook的macOS
[Blackshift](https://store.steampowered.com/app/741110/Blackshift/)在Steam上有售,现在少了一个bug
[RSS Feed](https://foon.uk/feeds/rss.xml)我写的东西和做的东西
1(https://foon.uk/blackshift-sand-bug/#ref1). 我认为这是一种假的环境光遮蔽。实际上,所有环境光遮蔽都是假的,以及所有其他计算机图形。
2(https://foon.uk/blackshift-sand-bug/#ref2). 边缘周围的四条四边形带成为任何与沙块相邻的非沙块的可见边缘。你可以看到它们作为图片中灰色块的垂直侧面。如果一个块不在那里,顶点着色器只是将它们全部向下移动出视野。
3(https://foon.uk/blackshift-sand-bug/#ref3). 为什么不把整个关卡的邻接图保存在纹理中?好吧,即使我这样做,着色器仍然必须知道要在纹理中查找哪些坐标,所以我仍然必须将正在绘制的单元格的坐标作为实例数据发送。所以,如果实例数据无论如何都是必要的,我不如直接发送值本身,而不是发送一些坐标去纹理中查找值。另外,bgfx限制你每个实例20个浮点数,而我只有一个空闲。我想我可以通过查看已经在占用这20个浮点数大部分的变换矩阵来重建单元格坐标;我认为那可以工作。但当时没想到。当然,如果我使用纹理,我还需要保持该纹理是最新的,而且我实际上需要几个纹理,因为Blackshift有时会同时绘制几个关卡(比如当你在Level Pit中滚动查看人们的关卡图片时;这些是同时渲染的,客户端)。这只是更复杂。
4(https://foon.uk/blackshift-sand-bug/#ref4). 为什么要使用浮点数?因为bgfx只支持浮点数。为什么要进行值转换而不是位转换?因为我支持旧版OpenGL,它不能进行位转换。
5(https://foon.uk/blackshift-sand-bug/#ref5). 这里(https://www.scratchapixel.com/lessons/3d-basic-rendering/rasterization-practical-implementation/perspective-correct-interpolation-vertex-attributes.html)有一个很好的透视正确插值解释。
6(https://foon.uk/blackshift-sand-bug/#ref6). 为什么不将varying变量标记为flat?答案是我仍然支持旧版OpenGL,那里flat不存在。
相似文章
@chessMan786:GPU架构基础
一条推文分享了一篇关于GPU架构基础的文章链接。
2026年GPU访问依然糟糕——有人正试图用计算期货市场来修复
Inferra正在构建一个GPU计算衍生品交易所,为H100、B200等芯片提供永续期货,实现价格发现和成本对冲,旨在解决不透明的GPU市场。
我为 Emacs 构建了一个 GPU 后端
作者描述了如何在 macOS 上使用 Metal、在 Linux 上使用 OpenGL 为 Emacs 构建基于 GPU 的显示后端,从而提升渲染性能并启用视频播放和动画光标等新效果,且无需修改核心重新显示引擎。
将我的C游戏移植到WASM,这是我遇到的所有Bug
一位开发者分享了将C游戏移植到WebAssembly的经验,详细介绍了因32位与64位差异遇到的Bug,并提供了调试技巧。
[P] 读了太多架构手册后,我构建了一个可移植的GPU ISA [P]
一个名为WAVE的可移植GPU ISA,将内核编译为通用二进制文件,并翻译成特定厂商的后端(Metal、PTX、HIP、SYCL),已在多个GPU上验证结果。