KLineChartQuant:GLSL 的精度问题及 RTC 解法

现象一句话版:桌面、手机、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 里可以声明三种浮点精度:lowpmediumphighp。规范只规定最低位数,实现可以用更高精度:

修饰符 规范最低 语义
lowp 9 bit 接近定点数
mediump 16 bit 大约 FP 16(半精度)
highp 32 bit 接近 IEEE-754 单精度

关键点来自 WebGL FundamentalsArm 官方文档

桌面 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_scrollX9980.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 规范里顶点着色器本来就默认 highpemscripten#8627)。
  • Re:Earth / Cesium 等全局场景引擎 专门讲 RTC / RTE 两种技巧,本质都是"把顶点坐标搬到大数附近,避免 GPU 上的大数运算丢精度"(high-precision-rendering)。
  • 即使声明了 highp,某些驱动还有各种精度回退怪癖(Khronos WebGL#3351 就记录了 Adreno 结构体成员精度被悄悄降半)。
华为马良(Maleoon)GPU 的处理:floathalf

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 里做"大世界坐标 - 大滚动偏移"的运算。理由有两个:

  1. 就算 highp,也只是 32 位单精度;滚动到数万根柱时,把「精确到 0.1 的大数」塞进 FP 32 依然有损失,只是损失比 FP 16 小得多。
  2. 依赖"升精度"需要每个 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 永远处理小数字

五、这次修复沉淀下来的三条原则

  1. 坐标系要有层次,别让 GPU 做长距离运算。 世界坐标 ↔ 屏幕坐标的换算放在 CPU,GPU 只处理视口局部的小坐标。
  2. 顶点着色器默认 highp 它是规范强制支持的,移动端也几乎都支持;片元着色器的 highp 才是可选项、要谨慎。
  3. "某类设备才出问题" = 数值精度问题。 桌面把所有精度都当 FP 32,测不出 FP 16 的错;要真正验证,要么真机,要么用 Chrome 的 --emulate-shader-precision 模拟移动精度(NVIDIA WebGL meetup)。

六、结语

一个看似"滚动不同步"的渲染 bug,最后落在一行 mediump 上。它提醒我们:在 GPU 上,"看起来一样的数字"在不同设备上精度可能差 16 倍

对我们来说,最有价值的不是记住"要写 highp",而是形成一个习惯:凡是涉及滚动、平移、缩放这类会在每帧改变的大偏移量,坐标换算尽量留在 CPU,让 GPU 只处理局部小数值。 这样无论将来设备怎么变、精度怎么降,都不会再踩同一个坑。


相关代码:KLineChartQuant(Canvas / WebGL / WebGPU 混合渲染的金融图表库),修复位于 WebGL 后端矩形上传前的坐标投影,以及 WebGL 顶点着色器的精度声明。

相关推荐
HarmonLTS1 小时前
智盾 WAF v8.2 Ultra|下一代 Web 应用防火墙
前端
এ慕ོ冬℘゜1 小时前
前端实战:基于jQuery递归实现通用树形组织菜单(可直接复用)
前端·javascript·jquery
计算机魔术师1 小时前
英伟达据报洽谈向 Mira Murati 的 Thinking Machines Lab 投资 25 亿美元
前端
变与不变8062 小时前
js函数与封装详细解答
前端·javascript·vue.js
南雨北斗2 小时前
Vue 3 项目中的拦截器的作用和写法(动态设置响应头适配表单文件上传)
前端
涛涛ing2 小时前
125秒到10秒:TypeScript 7.0用Go重写编译器,前端圈等了14年
前端
爱勇宝2 小时前
初创公司的“自己人”,到底能当多久?
前端·后端·程序员
晴天162 小时前
前端模块化规范全景解析:CommonJS、AMD、ESM、UMD
前端
Dream-Y.ocean3 小时前
基于具身交互智能数字人,我用Flask搭了一个AI教学平台:课代表AI全流程实战
人工智能·flask·交互