Leaflet 渲染 100 万 marker 卡成 PPT?我用 WebGL 重写了渲染器

地图项目里最常见的翻车现场,就是把大量点位直接扔给 Leaflet。

我们的场景是海量点数据的可视化:真实数据从几万到几十万不等。为了摸清这套方案的上限,我额外做了 100 万随机点的压力测试------先用 L.Marker 试,三千个点还能拖动,五万个点拖拽已经卡成逐帧动画;换成 Leaflet 自带的 Canvas 渲染器,移动时容器只是跟着做 transform,不会逐帧重绘,但视图一停下来,它要在 CPU 上把几万个矢量图形重新投影、逐个重画一遍,鼠标移动拾取还要线性遍历所有图形,数据量上来后同样顶不住。

折腾一圈后,我写了 leaflet-webgl-markers:一张 WebGL canvas 画下所有点,拖拽期间零重绘,点击拾取靠一个不可见的颜色缓冲完成。这篇文章把方案、实测数据和使用姿势完整拆开,你读完就能直接用到自己的项目里。

先记住三档性能边界

方案 现实可行的规模 拖拽/缩放表现 拾取方式
L.Marker(DOM) 几百到小几千 每个点都是一个 DOM 节点 DOM 原生事件
L.Canvas 渲染器 几万个矢量图形 移动时只变换容器,moveend 在 CPU 上一次性重画全部路径 每次指针事件遍历图形,O(n)
leaflet-webgl-markers 百万级(压力测试验证) 移动时 CSS transform,moveend 才重绘一次 FBO 颜色编码,O(1)

这里要替 Leaflet 说句公道话:它不是做得差,而是 DOM 节点和 CPU 侧路径绘制本来就不是为大数量级设计的。到了六位数以后,要换的不是用法,而是渲染工具。

核心设计:一张 canvas,把投影交给 GPU

整个方案浓缩成三点:

  • 经纬度放进顶点缓冲。 一个 marker 只是一小段 float,而不是一个 DOM 节点;100 万个点是几十 MB 的缓冲,不是 100 万个元素。
  • 墨卡托投影写在顶点着色器里。 CPU 不逐帧投影,(lat, lng) -> 世界像素 的数学在 GPU 上并行完成。
  • 拖拽期间什么都不画。 平移时 canvas 用 CSS transform 跟着地图走,不碰缓冲,每帧也不跑 JS;视图稳定后才在 moveend 做一次真正重绘。

"百万点不卡"的秘密就在第三条:昂贵的渲染发生在每次手势结束时的一次,而不是每一帧。日常的几万到几十万数据,更早之前就已经退出了危险区。

没有 DOM,点击从哪来:颜色编码 FBO

canvas 上的点没有 DOM 节点,点击怎么办?思路可以理解成给每个点涂一种肉眼看不见的"识别色"。

订阅交互事件(clickmouseover 等)后,图层会往帧缓冲里渲染第二遍不可见的 pass:每个 marker 不画真实颜色,而画一个唯一的编码颜色。指针事件发生时,只回读鼠标下的一个像素,再把颜色解码回对应 marker。

让它可靠的不只是这个点子,而是这些细节:

  • 拾取 O(1)。 一次指针事件只做一次 readPixels,和总点数无关。
  • 透明边缘也能点中。 3×3 高斯加权邻域处理抗锯齿边缘,看到的和点中的保持一致。
  • 像素与解码表原子发布。 永远不会读到"新像素 + 旧表"的错位组合。
  • 移动中不瞎猜。 拖拽或缩放动画期间,已发布的帧和视口不一致,此时拾取返回"不可用"而不是命中错误 marker,moveend 后自动恢复。
  • 没有监听就不创建。 不订阅交互事件时,拾取帧缓冲根本不存在,零额外开销。

性能实测:真正贵的是光栅化

显示 pass 和拾取 pass 的主要成本不在调用本身,而在 GPU 光栅化:它和可见点数 × 图标面积成正比。

用 100 万个随机点做压力测试,同一张 canvas 全部可见时:

  • 38px 图标:一次全量重绘约 200 ms
  • 8px 图标:约 50 ms

两个尺寸的面积相差约 22 倍,实测时间相差约 4 倍,说明像素填充是主项。日常几万到几十万的业务数据,按需控制尺寸后会更从容。所以这个包不内置 LOD 策略 ,只给你 setIconSize(number),尺寸怎么换性能由业务自己决定:

arduino 复制代码
const layer = new WebGLMarkerLayer({ iconSize: 38 })

map.on('zoomend', () => {
  layer.setIconSize(map.getZoom() <= 8 ? 8 : 38)
})

上手只需要几行

rust 复制代码
npm install leaflet leaflet-webgl-markers
javascript 复制代码
import L from 'leaflet'
import { WebGLMarker, WebGLMarkerLayer } from 'leaflet-webgl-markers'

const layer = new WebGLMarkerLayer({
  iconSize: 24,
  textureUrl: '/plane.png',
}).addTo(map)

// 批量加载:setMarkers 是快路径
layer.setMarkers(
  points.map(([lat, lng]) => new WebGLMarker({ latlng: [lat, lng] }))
)

layer.on('click', (e) => {
  // 只有命中 marker 才触发,e.marker 一定存在
  console.log(e.marker.data)
})

单个 marker 还支持 rotation(弧度)、color(RGB 染色)、size(独立尺寸)、visibleopacity 和任意业务 data。事件面是 mouseover / mouseout / click / dblclick / contextmenu,对齐 Leaflet 的交互层;需要弹窗时,还有可选的 popup 子模块。

真实数据 Demo,不只是 benchmark

在线 Demo 里放了四类场景,全部走同一个图层:

  • 约 4.8 万个真实机场,带缩放联动尺寸
  • USGS 实时地震时间轴,可播放、可拖动过滤
  • 大圆航线上的动态航班动画
  • 100 万随机点压力测试

在线 Demo:tang-tc.github.io/leaflet-web...

选型边界:什么时候用它,什么时候别用

  • 只支持 EPSG:3857。 投影数学写死在 shader 里,其他 CRS 会在 addTo 快速报错,而不是悄悄放错位置。
  • 需要 WebGL 1.0 和硬件加速。
  • 所有点共享一张纹理。 不能往 marker 里塞任意 DOM;少数需要自定义交互的点继续用 L.Marker
  • 画布内的点不支持键盘访问。 指针事件之外,键盘和 ARIA 方案需要业务层自己补。

简单说:大规模同构点位用这个;少量复杂交互 marker 用 DOM。两者还可以在同一张地图上共存。

最后

这套方案的完整源码、测试和文档都在仓库里,npm 也能直接装。如果你也在做海量点位的地图渲染,欢迎去 Demo 玩一圈,点个 star,或者把你在用的方案留在评论区。

相关推荐
用户921080262861 小时前
在 AI 代码生成项目里接入 Thought:别展示“玄学思维链”,只展示用户真正关心的工具调用
前端
艾醒(AiXing-w)1 小时前
LangChain 1.0 入门(二):LangChain 全模型标准化接入最佳实践(小白参数详解版)
前端·javascript·langchain
计算机魔术师2 小时前
我看了 Hugging Face 的 Daily Papers,发现这件事
前端
yu俞娥宝3 小时前
Codex官网前端可抄吗?技术拆解、风险边界与合规借鉴方案
前端
捧 花3 小时前
FastAPI 基础语法:从一个完整接口理解 Web API 的设计
前端·python·fastapi·middleware
Hilaku3 小时前
一行 CSS 新特性干掉 20 行 JavaScript ?
前端·javascript·程序员
JarvanMo3 小时前
AI 写代码暴增 161 倍,移动开发有没有变得更差?
前端
梦曦i4 小时前
RouterLink v2.5.0:H5端原生能力全面回归
前端·uni-app
小林ixn4 小时前
React + Zustand + JWT:从零实现登录鉴权与请求拦截
前端·react.js·前端框架