当给数字添加小数部分能修复你的着色器时
摘要
本文详细介绍了作者调试Voronoi图着色器的过程,解释了浮点数的小数部分如何导致动画卡顿,以及用于诊断和修复该问题的方法。
<p><a href="https://lobste.rs/s/ue8rof/when_adding_fractional_part_number_fixes">评论</a></p>
查看缓存全文
缓存时间: 2026/09/16 11:25
# 当浮点数的小数部分修复了你的着色器
来源:https://crocidb.com/post/when-the-fractional-part-of-a-float-fixes-your-shader/
前几天我想实现一个小型的沃罗诺伊图着色器,用作音乐视频的背景,以展示我最近完成的音乐项目。沃罗诺伊噪声是一种我从未实现过的算法,我一直被它那几何感十足且常常带有有机美感的外观所吸引。在没什么具体方向的情况下,我开始实现并尝试它。最终效果看起来相当酷,我差点就要收工了,但第二天早上我发现,在我的其中一台电脑上,动画出现了一种奇怪的卡顿。于是我决定深入研究,找出问题所在。这就是我一周来调试和反汇编着色器的整个冒险经历的总结。故事中有几个意想不到的转折,希望也能提供许多有趣的信息。
这是最终的着色器。你也可以在Shadertoy上查看它(https://www.shadertoy.com/view/7Xt3RS)。我还没开始制作视频,但你可以在所有数字平台上找到这首音乐:stuffy knows (https://www.tapelink.io/share/rdfYQLYn)。
这个着色器并不复杂。事实上,它基本上只是几个概念的组合:
### 沃罗诺伊图
这是一种被称为 **Worley噪声** 或 **沃罗诺伊噪声** 的变体,它使得单元格彼此区分。在这种情况下,我将空间划分为等大的网格,然后在这些网格内获取随机点(即 *沃罗诺伊中心*),最后计算每个像素到最近的9个沃罗诺伊中心的距离。最近的那个中心定义了该点属于哪个沃罗诺伊单元格。这生成了这样的单色效果:
黑白的沃罗诺伊图
黑白的沃罗诺伊图
The Book Of Shaders (https://thebookofshaders.com/12/) 中对沃罗诺伊噪声有一个非常好的介绍。我记得那是我学习着色器时最早看到的相关资源之一。那里还有很多其他非常酷的资源。
### UV 空间扭曲
这是一种空间扭曲。在将空间等分之前,我使用噪声来扭曲空间。将其应用于沃罗诺伊图,我得到了这个:
扭曲空间
扭曲空间
### 调色板查找
我基本上使用到中心的反向距离为沃罗诺伊单元格着色。所以现在,我终于从调色板中获取最合适的颜色(假设它们按我希望的顺序排列),并应用一点着色,因为我的调色板只有7种颜色:
```glsl
const vec3 palette[7] = vec3[7](
vec3(0.008, 0.451, 0.325), // #027353
vec3(0.090, 0.275, 0.090), // #174617
vec3(0.000, 0.455, 0.545), // #00748B
vec3(0.949, 0.361, 0.745), // #F25CBE
vec3(0.659, 0.580, 0.949), // #A894F2
vec3(1.000, 0.725, 0.820), // #FFB9D1
vec3(0.788, 0.949, 0.675) // #C9F2AC
);
/// ...
col = palette[i] + vec3(val - .5) * .8;
```
这生成了:
着色器的最终效果
着色器的最终效果
我还在调色板中移动,营造出这种跳跃的、移动的效果,我非常喜欢。
最终着色器可以在[这里](https://www.shadertoy.com/view/7Xt3RS)找到。
## 问题
第二天早上,我在另一台不同于最初构建着色器的电脑上工作,所以当我打开它继续调整时,我注意到一个问题:
有一种奇怪的卡顿,之前并未出现。起初我在Windows上的Firefox中测试,然后在Chrome中打开,接着是Edge。所有浏览器都显示出相同的问题。
于是我开始剥离效果,以找出问题所在。移除UV扭曲、调色板循环并增大单元格尺寸使问题更清晰了:
仅作对比,这是它应该呈现的样子:
似乎问题出在生成沃罗诺伊中心的代码部分:
```glsl
// 计算到所有点的距离
for (int i = -1; i <= 1; i++) {
for (int j = -1; j <= 1; j++) {
vec2 o = origin + (vec2(i, j) * tile_size);
vec2 v = o * 398.0 + vec2(iTime * 1.3, iTime * 1.4);
vec2 c = o + noise2(v) / TILES;
float d = distance(uv, c);
if (d < dist) {
dist = d;
point = o;
}
}
}
```
更具体地说,是在定义 `v` 和 `c` 的那几行。组合噪声调用的程序性规则以及调用本身。不知何故,那个噪声调用中的某些东西在这台电脑上的表现与我另一台电脑不同。
于是我用这些不同的设备进行了测试:
- 两台装有 **集成 Intel GPU** 的不同 Linux 笔记本电脑:**正常**
- Google Pixel 9 Pro 手机:**正常**
- 装有 **RTX 2070** 的 Linux 台式机:**正常**
- 装有 **RTX 4070** 的 Windows 台式机:**异常**
在这5台不同的设备中,只有最后一台出现了卡顿。我甚至在几乎所有设备上都尝试了不同的浏览器。我咨询了一些图形程序员朋友,但他们太忙了,无法帮助我。
所以我决定使用手头的工具开始调试。
### 噪声函数
为那些对着色器编程一无所知的人提供一个简要的简化介绍。着色器是在 GPU 上运行的程序。根据你处于图形管线中的哪个部分,有几种类型的着色器。Shadertoy 使用 **片段着色器**(也称为 **像素着色器**)。这是在 GPU 光栅化对象的顶点之后的阶段,这个程序负责生成最终的像素颜色。简而言之,程序本质上在 Shadertoy 视口中每像素每帧运行一次。它无法存储状态,因此没有副作用。可以把它想象成函数式编程:整个着色器就像一个 **纯** 函数,它接受一些输入,并始终基于这些输入生成相同的输出。这也是 Shadertoy 如此有趣的原因之一。
在我的着色器中,仅有两个参数会变化:
1. 当前像素的坐标(以2D向量形式传入,分量范围从 `0.0f` 到 `1.0f`);
2. 时间变量(一个浮点数,仅在帧与帧之间变化)。
为了获得 *随机* 值,我必须依赖 *哈希函数*,然后可能制作程序性噪声来 *平滑* 它。我在这个着色器中使用的噪声函数取自 Inigo Quilez (https://iquilezles.org/)(Shadertoy 的创作者,也是我所知最有影响力的图形程序员之一)关于程序噪声的文章(https://iquilezles.org/articles/morenoise/)。它与文章中的略有不同,但自2019年以来,我编写的几乎每个着色器中都使用着相同的代码。
这是这个着色器中使用的完整噪声代码。无需真正理解它,但它基本上获取不同值的哈希值并进行插值,从而有效地平滑输出。如果你想更好地理解,请查看 Inigo Quilez 的文章。
```glsl
float hash1(float n) {
return fract(n * 17.0 * fract(n * 0.3183099));
}
float noisev(in vec3 x) {
vec3 p = floor(x);
vec3 w = fract(x);
vec3 u = w*w*w*(w*(w*6.0-15.0)+10.0);
float n = p.x + 317.0*p.y + 157.0*p.z;
float a = hash1(n+0.0);
float b = hash1(n+1.0);
float c = hash1(n+317.0);
float d = hash1(n+318.0);
float e = hash1(n+157.0);
float f = hash1(n+158.0);
float g = hash1(n+474.0);
float h = hash1(n+475.0);
float k0 = a;
float k1 = b - a;
float k2 = c - a;
float k3 = e - a;
float k4 = a - b - c + d;
float k5 = a - c - e + g;
float k6 = a - b - e + f;
float k7 = -a + b + c - d + e - f - g + h;
return (k0 + k1*u.x + k2*u.y + k3*u.z + k4*u.x*u.y + k5*u.y*u.z + k6*u.z*u.x + k7*u.x*u.y*u.z);
}
```
## 第一次尝试查找问题
由于我的朋友们无法指导我调试,我向大语言模型提问,解释了这个问题并附上了完整代码。起初它开始分析哈希函数以查找连续性问题,但我认为这不是个好主意,因为这段代码在成千上万个 Shadertoy 着色器中都有使用。于是我引导它远离了那个方向。
然后它认为可能是噪声函数开头调用 `fract` 的问题。显然它在网上发现了一些人抱怨在 Nvidia 40xx 驱动中使用它存在问题。
> `fract` 获取浮点数的小数部分。
阅读 *思考* 过程时,我注意到大语言模型(Kimi K3)假设卡顿是在代码运行一段时间后才开始的,这确实会降低浮点数的精度,导致可见的卡顿。这是很常见的情况,但在这个案例中,卡顿从第一秒就开始了。不过我还是决定尝试它给出的第一个建议:将 `fract(x)` 替换为 `x - floor(x)`,这在数学上是等价的。
**它起作用了。** 问题解决了。我再也看不到沃罗诺伊顶点在跳动了。
我请求解释,它给了我这段:
> “**根本原因:**Nvidia 驱动程序 595.79(RTX 4070)在这个着色器的上下文中,对于大数值操作数(~100-1000),错误编译了 GLSL 的 `fract()` 用法:返回的小数部分在时间上是不连续的(平滑移动,然后跳跃)。证明:用数学上等价的 `x - floor(x)`(已作为 `p` 计算——精确的 fp32 减法)替换 `fract(x)`,消除了在受影响机器上的卡顿,同时在健康的 GPU 上产生了位相同的输出。根据定义,一个根据发出的指令不同而以不同方式计算相同方程的编译器,就是错误编译。
> **对着色器的影响:**`fract(v)` 是值噪声的插值权重;当它出错时,噪声输出会跳跃而不是平滑移动——并且沃罗诺伊中心(`c = o + noise2(v)/TILES`)也随之跳跃。”
这时候我本可以继续我的生活。着色器工作了,我可以回去调整它,改进代码,然后发布它。但我太过好奇,而且不太信服,于是我决定深入挖掘。
## 重现问题
自然的下一步是找到能重现该问题的最小可能代码。于是我又问了大语言模型,因为它已经提出了生成修复的假设。它失败了。我换了几个模型,甚至用了像 Opus 这样的专有模型,但没有一个能创建出重现该问题的程序。它生成的所有测试着色器都基于一个假设,即 `fract` 在某些特定输入值范围内生成了垃圾值,它们都是显示预期值 `x - floor(x)` 和有问题的 `fract(x)` 之间差异的变体。
这些测试有趣的结果是,在我所有的设备上,要么**完全没有差异**,要么错误不仅发生在有问题的设备上。这推翻了整个关于它是 GPU 驱动程序问题的假设。大语言模型找到了一个解决方案,但纯属偶然!
## 抛弃大语言模型,自己动手
我开始移动代码并思考如何简化调用噪声函数的循环,同时保持类似的参数。我还尝试将不同的值传递给噪声函数,就在这时我发现了第一个转折:**只要传递给噪声的参数中至少包含一个浮点小数乘法,着色器就能正常工作**。
例如,原始代码中的这行:
```glsl
vec2 v = o * 398.0 + vec2(iTime * 1.3, iTime * 1.4);
vec2 c = o + noise2(v) / TILES;
```
只要我将标量乘数从 `398.0` 改为 `398.1`:
```glsl
vec2 v = o * 398.1 + vec2(iTime * 1.3, iTime * 1.4);
```
卡顿就消失了。即使噪声代码中的 `fract` 仍然存在。这是最重要的证据,但也最奇怪。即使我乘以 `1.0`,或者完全去掉乘法,卡顿依然存在,但把它加回来,比如 `1.001`,问题就解决了。
是时候反汇编了。我想知道仅仅改变一个字面量浮点值,最终的机器代码中会发生什么变化。
## 反汇编着色器
我没什么调试着色器的经验,对 GPU 架构也几乎一无所知。我所有的图形知识都更侧重于管线(早些时候尝试创建3D渲染器和游戏引擎时:annileen (https://github.com/crocidb/annileen)),这些发生在图形 API 层面。但调查这类问题是我喜欢的事情,即使对 GPU 汇编没有太多了解,我也知道通过观察它,我能理解很多正在发生的事情。
早些时候开发 **annileen** 时,我曾不得不使用一些 RenderDoc (https://renderdoc.org/),这是一个开源图形调试器软件,允许你深入查看一帧的整个图形管线,包括获取每个着色器的编译版本以及所有输入输出的数据。但我预料到仅仅为了一个 WebGL 上下文而调试整个浏览器有点小题大做,于是我又调用大语言模型生成了一个 OpenGL 包装程序,运行相同模型的 GLSL 并传递与 Shadertoy 相同的 uniform 变量。一个本地程序,可以加载 `shader.glsl` 并完全像 Shadertoy 一样显示它。
烧掉一些 token 后,程序运行起来了,但是……没有卡顿。我确保使用的是正确的代码,但就是无法重现错误。我假设这只是因为它是 OpenGL 而不是 WebGL(OpenGL ES),于是放弃了这次测试。我必须捕获一个浏览器帧。
## 使用 RenderDoc 捕获浏览器帧
我有一个糟糕的习惯:安装了多个浏览器,每个都有特定的标签页设置。于是我下载了一个全新干净的 Chromium 版本。我在某处找到了在 RenderDo 中完整捕获 Chromium 会话的正确启动命令行参数:
```
--disable-gpu-sandbox --disable-gpu-watchdog --no-sandbox --ignore-gpu-blocklist --enable-webgl --use-angle=d3d11 --disable-direct-composition
```
并设置它也从子进程捕获,因为它会创建几个不同的进程。启动后,我打开了另一个我创建的只包含着色器视口的包装程序,我可以用 **F12** 捕获帧,并打开这些隐藏在子进程中的捕获:
现代浏览器会生成多个子进程
现代浏览器会生成多个子进程
结果它总是在第二个子进程中:
我用不同值做的两个捕获
我用不同值做的两个捕获
我进行了两次捕获,一次是使用原始着色器,值为 `398.0`,另一次是 `398.1`。
要找到反编译的着色器,我需要做的就是在 *事件浏览器* 中找到正确的绘制调用:
绘制我的着色器的那个特定绘制调用
绘制我的着色器的那个特定绘制调用
然后转到管线状态选项卡,选择像素着色器:
像素着色器 31872 *像素着色器 31872*
接着点击着色器程序前面的查看按钮:
终于看到了着色器反汇编
终于看到了着色器反汇编
这时我漏掉了一个非常重要的事实:着色器是 `ps_5_0` 格式。这是 DirectX 11 的格式,完全不是 OpenGL 的格式。我最终会回到这一点上。我只是想检查两个着色器程序之间的区别,一个是标量乘以噪声输入的值为 `1.0`,另一个是 `1.1`。
而结果是 **非常令人惊讶** 的,两个着色器差异很大:
仅仅改变一个字面量浮点值就产生了如此多的变化
仅仅改变一个字面量浮点值就产生了如此多的变化
你可以看到第 `43` 行的值不同。其余部分主要是不同的寄存器和指令,尽管最终代码结构相同。
出于好奇,我拿到了用 `x - floor(x)` 技巧替代 `fract` 的程序的反汇编,仍然传递一个没有小数部分的标量值(这里是 `3.0`),以及使用 `fract` 但传递 `3.1` 的版本。而结果让我大吃一惊
相似文章
我今天学到了关于GPU的知识
一位游戏开发者讲述了他在游戏《Blackshift》中修复GPU渲染bug的经历。问题是将8位邻接整数转换为浮点数时出现的浮点数精度问题,导致在部分NVIDIA GPU上出现视觉瑕疵,且该bug只在主渲染模式中出现,预览模式中并未出现。
将整数除法转换为浮点除法是微不足道的
一篇技术博客文章,解释了如何使用浮点除法和融合乘加来执行整数除法和取余,并给出了操作数位宽的限制,同时讨论了SIMD和舍入模式的实际注意事项。
当浮点数除法胜过整数除法
一篇博客文章,解释了一个反直觉的优化现象:在现代CPU上,使用浮点数除法(DIVSD)比整数除法(IDIVQ)性能更佳,并附有基准测试和汇编分析。
#つぶやきGLSL float i,e,R,s;vec3 q,p,d=vec3((FC.xy-.5*r)/r.y,.6);for(q.z--;i++<97.;i>86.){o.rgb+=hsv(.08,-e,e/5e1)+.003;p=q…
一条由用户 @YoheiNishitsuji 发布的推文,分享了一个紧凑的GLSL着色器程序(分形/光线步进实现)
分形抖动Voronoi分区
本文阐述了使用抖动网格方法在Voronoi分区中创建分形边界的方法,该方法在海岸线模拟等应用中效率较高。