Floyd-Steinberg 抖动在图形学教科书里是个顺序算法:每处理完一个像素,才能把误差扩散给相邻像素,下一个像素才有值可算。这意味着它天然不适合 GPU 的并行执行模型。但 ditherwave 在 WebGL2 Shader 里实现了它的近似版本,体积压缩后不超过 8kb,在 1080p 分辨率下实现 60fps,初始化之后零单帧内存分配。
这是 ditherwave,一个面向浏览器的 WebGL2 实时抖动渲染库。

顺序算法的 GPU 悖论
经典抖动算法分两类。一类是有序抖动,比如 Bayer 矩阵:用预先计算好的阈值查表,每个像素独立决策,天然可以并行,GPU 上跑起来没有障碍。另一类是误差扩散,Floyd-Steinberg 是代表:处理当前像素时把量化误差按固定权重分给右边、左下、正下、右下四个邻居,下一个像素读取累计误差再做决策。
问题在于「读取累计误差」这一步。CPU 上靠顺序写内存解决,GPU 上每个线程处理不同像素,写入和读取的顺序没有保证,得到的误差是错的。
标准做法是放弃在 GPU 上做真正的 Floyd-Steinberg,要么退回 CPU 实现,要么用有序抖动替代。ditherwave 选了第三条路。
ditherwave 的做法
Floyd-Steinberg 的核心价值不是具体的误差扩散路径,而是「让相邻区域的量化误差趋于均衡」这个效果。ditherwave 用 Hilbert 曲线游走采样来逼近这个效果。
Hilbert 曲线是一条空间填充曲线,关键特性是局部连续:曲线上相邻的两个点,在二维平面上也倾向于相邻。沿曲线顺序游走时,处理到当前像素的「前一个像素」大概率就在附近,误差扩散的空间局部性得以保留。ditherwave 在 Shader 里用数学公式实时计算 Hilbert 曲线索引,把「顺序依赖」转化成「空间局部采样」,从而在 GPU 并行模型里跑出接近 Floyd-Steinberg 的视觉效果。
官方把这个模式命名为 Floyd(Riemersma 近似),名字来自 Thiadmer Riemersma 在 1990 年代提出的类似思路。
整体渲染流水线分两段:
初始化阶段:创建 WebGL2 上下文,编译 Shader 程序,上传纹理(图片/视频帧/Canvas 像素),分配所有渲染需要的缓冲区和帧缓冲对象。这一步做完之后,后续帧不再申请新的 GPU 或 CPU 内存。
帧循环阶段:每帧只做三件事——更新时间 uniform(用于动画插值)、更新输入纹理(视频源每帧刷新,静态图片跳过)、发起一次 draw call。对于 <DitheredWaves> 组件,输入纹理本身就是 Shader 里用 fBm 噪声实时生成的,不需要外部图像源。
// Bayer 矩阵抖动的核心 Shader 逻辑(简化)
float bayer8[64] = float[64](...); // 预存 8x8 Bayer 矩阵
vec2 coord = fract(gl_FragCoord.xy / 8.0) * 8.0;
float threshold = bayer8[int(coord.y) * 8 + int(coord.x)] / 64.0;
vec3 quantized = floor(color * (colorNum - 1.0) + threshold) / (colorNum - 1.0);四种模式共用同一套上下文初始化代码,仅在 Shader 的量化逻辑分支上有差异:Bayer 用矩阵阈值查表,Floyd 用 Hilbert 曲线索引,Dots 用旋转 15° 的圆形 SDF,ASCII 用字符灰度纹理采样。
零分配的设计意义
ditherwave 在文档里明确标注「零单帧分配」,这在 60fps 渲染场景里不只是数字上的优化。
JavaScript 的 GC(垃圾回收)不可预测。如果每帧都在堆上创建新对象(哪怕是小对象),GC 会在某个不可控的时刻触发停顿,表现为帧率突然掉到 30-40fps 再恢复。视觉上是明显抖动。ditherwave 把所有缓冲区在初始化时一次性分配完,帧循环只复用已有内存,GC 触发频率大幅降低。
IntersectionObserver 暂停是另一个针对页面滚动场景的优化。当 Canvas 元素滚出视口,ditherwave 停止 requestAnimationFrame 循环;滚回来时重新启动。对于在长页面里嵌入多个抖动效果的场景,这直接决定了页面后台的 CPU/GPU 占用是否可控。
8kb 的体积约束也不只是技术展示。WebGL2 Shader 本身是字符串,必须在运行时编译,ditherwave 选择把四套 Shader 逻辑内联进库文件而不是动态 fetch,好处是零网络请求、零加载时序问题,代价是必须把所有代码压缩到极致。TypeScript 占了仓库 78.9% 的代码量,CSS 占 20.9%,没有额外的运行时依赖。
输出模式
<Dither> 接受任意 HTML 元素作为子节点,通过 CSS object-fit 和 position 把 Canvas 覆盖在原始内容上方。视频源每帧自动读取 videoWidth × videoHeight 像素,动态 Canvas 内容通过 getImageData 捕获。静态图片只在首帧上传一次纹理,不重复读取。
<DitheredWaves> 不依赖外部输入源,全部由 Shader 内部的 fBm(分形布朗运动)噪声驱动,支持 11 个参数控制波形频率、振幅、颜色渐变和鼠标交互响应。适合做全屏背景或页面 hero 区域的动态效果。
两个组件都提供 ditherwave/vanilla 入口,暴露 createDither() 和 createDitheredWaves() 工厂函数,可以在 Vue、Svelte 或无框架项目里直接挂载到任意 DOM 节点。
写在最后
ditherwave 目前 36 颗星,处于早期阶段。已知限制是 WebGL2 的浏览器覆盖率——Safari 15 以下和部分旧版 Android 浏览器不支持,需要做降级处理。Floyd 模式的 Hilbert 近似和真正的 Floyd-Steinberg 在边缘细节处有视觉差异,颜色数量多时差距更明显。
对在浏览器里做创意视觉效果的开发者值得一看,特别是需要把抖动效果嵌进已有 React/Vue 应用且对体积敏感的场景。想研究 GPU 并行模型下近似顺序算法的也可以直接看 Shader 源码。
GitHub:https://github.com/sahilsaini5/ditherwave (opens in a new tab)