现象一句话版:桌面、手机、Canvas 2D 全部正常,偏偏只有移动平板端,极值标记一直跟着手指平滑移动,K 线却要顿一下才跳到位。
排查到最后,既不是滚动事件丢帧,也不是坐标算错,而是一行 precision mediump float; 在移动 GPU 上引发的精度陷阱。本文记录完整的排查思路、根因,以及一类可以在任何 WebGL 2D 渲染器里复用的修复模式。
一、现象:分层的"不同步"
KLineChartQuant 是一个支持 Canvas / WebGL / WebGPU 三种后端的金融图表库,发现问题:滚动时极值指示器(可视区最高 / 最低价标注)一直平滑跟手,而 K 线却"走了好一段才跳一格"。补充信息非常关键:
- Canvas 2D 后端:正常
- 桌面端 WebGL:正常
- 手机 WebGL:正常
- 只有低性能平板 WebGL:异常
"只有某种设备异常"是精度类 bug 最典型的信号。它意味着代码逻辑本身没变,而是硬件 / 驱动对同一段代码给出了不同结果。
二、先排除"看似合理"的答案
排查初期,最直观的怀疑是:滚动事件没同步好。图表有两套坐标来源:
- K 线:由
scrollLeft(滚动偏移)驱动,每帧重算可见区与柱坐标 - 极值标记:在 overlay 画布上根据
kLineCenters(柱中心)绘制
于是我们补上了滚动状态同步、统一了"先扣滚动量、再按 DPR 对齐"的投影函数,也在逻辑上把两套坐标收敛到同一帧快照。逻辑上已经完全对齐了------但现象没消失。而且桌面端从未出现此类不跟手问题。
当"所有逻辑都对齐、却仍只有某类设备错"时,答案基本只剩一个:数值精度。
三、根因:mediump 在移动 GPU 上真的是低精度
3.1 精度修饰符是什么
WebGL 的 GLSL 里可以声明三种浮点精度:lowp、mediump、highp。规范只规定最低位数,实现可以用更高精度:
| 修饰符 | 规范最低 | 语义 |
|---|---|---|
lowp |
9 bit | 接近定点数 |
mediump |
16 bit | 大约 FP 16(半精度) |
highp |
32 bit | 接近 IEEE-754 单精度 |
关键点来自 WebGL Fundamentals 和 Arm 官方文档:
桌面 GPU 几乎总是把所有精度都当作
highp来跑。所以你在mediump下写的 shader,在桌面上永远测不出问题 ------因为它实际上跑的是 FP32。 移动 GPU 才真正以 16 bit 半精度执行mediump。
这正是"桌面正常、手机正常、唯独低性能平板异常"的根源:桌面把 mediump 升格为 highp,而低性能平板(往往采用降频或精简的 Mali / 入门 Adreno 系列)按 FP16 执行 。手机正常可能只是恰好那颗 GPU 也把 mediump 当了 highp。
Unity 官方文档里有一张很直白的移动 GPU 精度对照表,显示几乎所有移动 GPU 的 float 是 32 bit、half 只有 16 bit;而 PC GPU 无论写什么都是 32 bit。
3.2 为什么滚动时精度会崩
我们的 K 线顶点着色器长这样(已简化):
precision mediump float; // ← 问题在这行
in vec4 a_rect; // 柱的世界坐标 (x, y, width, height)
uniform float u_scrollX; // 当前滚动偏移
void main() {
vec2 position = vec2(
a_rect.x - u_scrollX, // 大数相减!
...
);
}
假设数据里有几万根 K 线,滚动到靠后位置时,a_rect.x 可能是 10000.8 ,而 u_scrollX 是 9980.6,期望结果是 20.2。
问题在于:
- FP16(
mediump)只有约 11 位有效尾数 。当数值达到 10000 这个量级时,FP 16 能表达的"最小步长"已经不是 0.1,而是粗得多(大约 8 个单位级)。于是10000.8 - 9980.6在两个都经过 FP 16 量化的大数上进行,结果不是 20.2,而是被吞成 16 或 24 这类"跳格"的值。 - 每滚动一丁点,
u_scrollX的增量都在 FP 16 的量化阈值以下,被抹平;只有当位移累计到足以改变量化结果时,K 线才"啪"地跳一格。 - 而极值标记走的是 Canvas 2D 路径,在 CPU 上用 64 位双精度 计算,
worldX - scrollLeft精确无损失------所以它一直平滑跟手。
于是两条渲染路径在数值精度上"分层"了:一条双精度、一条 FP 16。
3.3 这是个普遍问题,不止 K 线
这其实是一个被业界反复踩过的坑:
- Mapbox GL 长期维护"在移动设备上把 shader 精度提升到
highp"的修复,维护者在 issue 里直言:"顶点着色器应该无条件使用highp,几乎没有坏处"(mapbox-gl-js#2096)。 - Emscripten 曾给所有顶点着色器默认加
precision mediump float;,结果被 Greggman 指出这会在移动端破坏内容------因为 GLSL ES 规范里顶点着色器本来就默认highp(emscripten#8627)。 - Re:Earth / Cesium 等全局场景引擎 专门讲 RTC / RTE 两种技巧,本质都是"把顶点坐标搬到大数附近,避免 GPU 上的大数运算丢精度"(high-precision-rendering)。
- 即使声明了
highp,某些驱动还有各种精度回退怪癖(Khronos WebGL#3351 就记录了 Adreno 结构体成员精度被悄悄降半)。
华为马良(Maleoon)GPU 的处理:float 与 half
GPU 厂商自己的官方实践最能佐证这一点。华为《马良(Maleoon)GPU 最佳实践》官方文档对 float/half 精度的处理,和我们这次修复几乎是同一个结论:
-
顶点着色器精度统一按
highp实现。 官方原文:为了规避 Vertex shader 计算位置信息的偏差导致后续 shader stage 误差放大,Maleoon GPU 上 vertex shader 精度统一按照 highp 实现。推荐使用 highp 设置。
马良是 Tile-Based (TBDR)架构的 GPU,官方把"顶点位置信息用高精度"当作规避误差被后续阶段放大的硬性原则------正是我们博客里说的"顶点着色器默认
highp"。 -
片元着色器推荐
highp,但要留意输出精度匹配。 官方一方面推荐在 fragment shader 中使用highp,另一方面专门警告了"输出颜色值与 color attachment format 精度不匹配"的突变问题:若 color format 是VK_FORMAT_B10G11R11_UFLOAT_PACK32,11-bit float 数值越大精度越差------1024.0f能表示,1024.1f被存成1040.0f,导致相邻像素间出现视觉突变。 -
精度的总体倾向:降精度是省带宽和功耗的手段,但不是无脑用低精度。 涉及位置、矩阵变换、纹理坐标等参与坐标计算的,用
highp;颜色、光照等能接受约 3 位小数误差的,才考虑half(16 bit)以省功耗。华为还提供 Graphics Profiler 里的 "Half-float Instructions" 计数器,用于量化半精度指令占比,指导该不该降精度。
对照下来,马良官方的处理恰好同时印证了我们修复里的两个点:"顶点着色器用 highp" (官方也是这么定死的),以及 "位置坐标别用低精度参与计算"(官方强调位置信息误差会被后续 stage 放大)。
顺带一提,这类"只在某类移动 GPU 上爆发"的精度问题在移动端非常普遍------比如经典的 Mali GPU FP16 HDR 辉光抖动案例,就是因为值达到了 FP16 能表示的最大值
65504而炸掉,把 roughness 的精度从half提回float才修好(Mali 浮点数异常)。
所以我们的修复思路,正好是"改一行 + 改一个架构习惯"的组合。
四、修复:把大数相减从 GPU 挪回 CPU
4.1 第一招:顶点着色器升到 highp
precision highp float; // 顶点着色器默认就该是 highp
这一步让移动 GPU 上以 FP 32 执行顶点运算,立刻解决"大数相减"的精度问题。对大多数现代移动 GPU,顶点着色器本来就支持 highp(规范要求顶点必须支持),所以几乎无兼容性风险。
注意:片元着色器里
highp是可选 的,老设备不支持、会编译失败。所以只对顶点 着色器升highp是安全且推荐的;片元着色器保持mediump通常足够(就像 Chrome 官方博客建议的那样:use-mediump-precision-in-webgl-when-possible)。
4.2 第二招(更治本):不在 GPU 里做世界坐标相减
光升 highp 就够了吗?对这个问题够,但对架构不够好。
我们真正想做的是:永远不要在 GPU 里做"大世界坐标 - 大滚动偏移"的运算。理由有两个:
- 就算
highp,也只是 32 位单精度;滚动到数万根柱时,把「精确到 0.1 的大数」塞进 FP 32 依然有损失,只是损失比 FP 16 小得多。 - 依赖"升精度"需要每个 shader 都记得写对,一旦漏掉一个就又回到 FP 16 陷阱。与其到处贴膏药,不如从源头消除"大数相减"。
于是我们把 K 线的世界坐标投影(worldX - scrollLeft,再按 DPR 对齐到物理像素)在 CPU 的双精度空间 提前算好,只把小坐标(视口局部坐标)上传给 GPU ,shader 里 u_scrollX 直接传 0:
// CPU:双精度下精确完成 世界坐标 → 视口局部物理像素
const screenLeft = round((worldLeft - scrollLeft) * dpr) / dpr
const screenRight = round((worldRight - scrollLeft) * dpr) / dpr
rectScreen[offset] = screenLeft
rectScreen[offset + 2] = Math.max(1 / dpr, screenRight - screenLeft)
现在 GPU 拿到的坐标是几十、几百这种小数值,即使某个 shader 忘了写 highp、被当成 FP 16,也远不会踩到量化阈值。小数值对小精度天然免疫,这才是根本解法。
这正是地图引擎里 RTC (Relative-To-Center)的二维版本:把坐标搬到局部原点附近,让 GPU 永远处理小数字。
五、这次修复沉淀下来的三条原则
- 坐标系要有层次,别让 GPU 做长距离运算。 世界坐标 ↔ 屏幕坐标的换算放在 CPU,GPU 只处理视口局部的小坐标。
- 顶点着色器默认
highp。 它是规范强制支持的,移动端也几乎都支持;片元着色器的highp才是可选项、要谨慎。 - "某类设备才出问题" = 数值精度问题。 桌面把所有精度都当 FP 32,测不出 FP 16 的错;要真正验证,要么真机,要么用 Chrome 的
--emulate-shader-precision模拟移动精度(NVIDIA WebGL meetup)。
六、结语
一个看似"滚动不同步"的渲染 bug,最后落在一行 mediump 上。它提醒我们:在 GPU 上,"看起来一样的数字"在不同设备上精度可能差 16 倍。
对我们来说,最有价值的不是记住"要写 highp",而是形成一个习惯:凡是涉及滚动、平移、缩放这类会在每帧改变的大偏移量,坐标换算尽量留在 CPU,让 GPU 只处理局部小数值。 这样无论将来设备怎么变、精度怎么降,都不会再踩同一个坑。
相关代码:KLineChartQuant(Canvas / WebGL / WebGPU 混合渲染的金融图表库),修复位于 WebGL 后端矩形上传前的坐标投影,以及 WebGL 顶点着色器的精度声明。
