引言
Canvas 2D 的绘制成本与像素面积相关,十万个点还能勉强应付,百万个点就开始掉帧——因为它是一个 CPU 逐点调用的绘图 API。WebGL 把绘制指令交给 GPU 并行执行,同样的百万点可以在 60fps 下流畅渲染,代价是整个渲染模型从「调用 API 画图」变成了「管理 GPU 状态与缓冲区」。这条跃迁不是性能优化的微调,而是范式转换。
三维则带来另一层复杂度。二维图表里,数据点与屏幕像素是简单的仿射映射;三维里,位置要经过模型、视图、投影三级变换,还涉及深度测试、光照、遮挡。多数「三维可视化」需求其实是伪需求——用三维展示一个二维数据(如柱状图加个 Z 轴)只会降低可读性。三维真正不可替代的场景只有一类:数据本身有空间结构(点云、轨迹、体数据、三维网络)。
本文按「何时用 → 渲染基础 → deck.gl 图层 → 大规模渲染 → 拾取 → 优化 → 选型」的顺序展开。与 地理关系与网络图可视化 的分工是:那篇讲二维地图与关系图的 deck.gl 用法,本文聚焦三维场景、点云、实例化、拾取与 LOD 这些 WebGL 特有的工程问题。
目录
- 何时需要 WebGL 与三维
- 渲染管线与坐标系基础
- deck.gl 的图层模型
- 大规模点云与轨迹渲染
- GPU 实例化与批渲染
- 拾取(picking)的实现
- LOD 与视锥裁剪
- 相机、投影与交互
- three.js 与 deck.gl 的分工
- 二维与三维的选型边界
- 性能诊断与调优
- 移动端与降级策略
1. 何时需要 WebGL 与三维
先判断「要不要 WebGL」,再判断「要不要三维」。两个问题独立。
需要 WebGL 的信号:元素数超过十万且需要交互(点云、大规模散点、轨迹);需要逐帧重绘的实时场景(流式数据的连续渲染);需要 GPU 端计算(着色器做聚合、过滤)。不需要的信号:元素数在几万以内(Canvas 2D 足够);图表是静态或低频更新(SVG 更易维护)。
需要三维的信号:数据有真实的三个空间维度(点云、建筑、地质体);需要视角旋转才能理解的结构(三维网络、分子结构、地理高程)。不需要的信号:只是想让二维图「看起来炫」;数据的第三维可以用颜色或大小表达。
| 场景 | 二维 Canvas | WebGL 二维 | WebGL 三维 |
|---|---|---|---|
| 万级散点 | 够用 | 更快 | 不需要 |
| 百万级点云 | 卡顿 | 可用 | 首选 |
| 地理轨迹 | 够用 | 首选 | 需高程时用 |
| 三维网络 | 不可 | 不可 | 首选 |
| 实时流式 | 勉强 | 首选 | 视需求 |
「伪三维」是常见的浪费:3D 饼图、3D 柱状图、加了厚度的平面图。它们用透视压缩了数据、增加了遮挡,却不编码任何新信息。判断标准是「第三维是否承载数据」——不承载,就不该用三维。
2. 渲染管线与坐标系基础
理解 WebGL 的渲染管线是调优的前提。一次绘制经历六个阶段:
顶点数据(Buffer)
→ 顶点着色器(Vertex Shader):模型→世界→视图→裁剪空间
→ 图元装配(Primitive Assembly)
→ 光栅化(Rasterization)
→ 片元着色器(Fragment Shader):逐像素着色
→ 深度测试 + 混合(Depth Test / Blending)
→ 帧缓冲(Framebuffer)
坐标系的四级变换是三维的核心:
| 空间 | 含义 | 变换 |
|---|---|---|
| 模型空间 | 物体自身坐标 | 模型矩阵 |
| 世界空间 | 场景统一坐标 | 视图矩阵 |
| 视图空间 | 以相机为原点 | 投影矩阵 |
| 裁剪空间 | 归一化设备坐标(NDC) | 视口变换 |
// deck.gl 中传入的坐标是「世界空间」,投影由视图矩阵处理
new ScatterplotLayer({
data: points, // 每个点有 [x, y, z] 世界坐标
getPosition: (d) => [d.lng, d.lat, d.altitude],
getRadius: (d) => d.r,
});
**深度测试(depth test)**决定遮挡关系:片元的深度值小于深度缓冲中的值时通过,否则被丢弃。它是三维正确性的基础,也是性能开销之一——不需要遮挡的图层可以关闭深度测试换取速度。**混合(blending)**处理透明度,代价更高(需要按深度排序),是三维可视化里最容易拖慢帧率的操作。
3. deck.gl 的图层模型
deck.gl 把可视化抽象成图层(Layer)的堆叠,每个图层负责一类几何体的渲染。
import { Deck, ScatterplotLayer, PathLayer, ColumnLayer } from '@deck.gl/core';
const deck = new Deck({
initialViewState: { longitude: 116.4, latitude: 39.9, zoom: 12, pitch: 45, bearing: 0 },
controller: true,
layers: [
new ScatterplotLayer({
id: 'stations',
data: stations,
getPosition: (d) => d.coords,
getRadius: (d) => d.radius,
getFillColor: (d) => d.color,
pickable: true,
}),
new PathLayer({
id: 'routes',
data: routes,
getPath: (d) => d.path,
getWidth: 2,
widthUnits: 'pixels',
}),
],
});
图层是声明式的:传入 data 与访问器(accessor)函数,deck.gl 负责把数据转成 GPU 缓冲区并绘制。更新数据只需重设 layers 数组,deck.gl 会做 diff——相同 id 的图层若数据未变则跳过重建。
常用的数据可视化图层:ScatterplotLayer(点)、PathLayer(路径/轨迹)、PolygonLayer(面)、ColumnLayer(柱,可做三维柱状)、HexagonLayer / GridLayer(聚合)、ArcLayer(弧线,适合流向)、PointCloudLayer(点云)、GeoJsonLayer(地理要素)、TextLayer(标签)。选择合适的图层比自定义着色器重要得多——多数需求都有现成图层。
图层顺序即绘制顺序。后声明的图层画在上层。三维场景里还要考虑深度测试与图层的交互——开启深度测试的图层之间遮挡正确,关闭的图层按声明顺序覆盖。
4. 大规模点云与轨迹渲染
点云是 WebGL 可视化最能体现优势的场景。一百万点用 Canvas 2D 会卡死,用 PointCloudLayer 或自定义着色器可以流畅渲染。
数据格式要优化。原始 JSON 数组(每个点一个对象)在百万级下解析就要几秒,内存占用也高。用 TypedArray 或二进制格式(如 LAS、PLY、或自定义的 Float32Array)能显著降低解析与内存开销。
// 二进制点云:位置与颜色打包成 TypedArray,零解析开销
const positions = new Float32Array(count * 3); // x, y, z 交错
const colors = new Uint8Array(count * 4); // r, g, b, a
new PointCloudLayer({
id: 'cloud',
data: [{ position: { value: positions, size: 3 }, color: { value: colors, size: 4 } }],
coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
pointSize: 2,
});
轨迹渲染的三种表达。路径线(PathLayer):把轨迹点连成线,适合少量轨迹。热力累积:把大量轨迹的经过次数聚合成热度,适合「路径密度」这类问题。逐段着色:按时间或速度给轨迹段着色,用颜色编码第四个维度。
轨迹数据量大时要做分段与抽稀。GPS 轨迹常有大量冗余点(直线段上的密集采样),用道格拉斯-普克(Douglas-Peucker)算法抽稀能在保持形状的前提下把点数降低一个数量级。
// 道格拉斯-普克抽稀:保留形状特征,去掉直线上的冗余点
function simplify(points, tolerance) {
if (points.length <= 2) return points;
const [first, last] = [points[0], points[points.length - 1]];
let maxDist = 0, index = 0;
for (let i = 1; i < points.length - 1; i++) {
const dist = perpendicularDistance(points[i], first, last);
if (dist > maxDist) { maxDist = dist; index = i; }
}
if (maxDist > tolerance) {
return [
...simplify(points.slice(0, index + 1), tolerance).slice(0, -1),
...simplify(points.slice(index), tolerance),
];
}
return [first, last];
}
5. GPU 实例化与批渲染
**实例化(instancing)**是 WebGL 处理大量相同几何体的核心机制。它把一份几何数据(如一个圆柱的顶点)上传一次,然后用实例属性描述「每个实例的位置、大小、颜色」,GPU 一次性画出所有实例。
// 顶点着色器中通过 gl_InstanceID 或实例属性区分每个实例
attribute vec3 instancePosition; // 每个实例的位置(实例属性)
attribute vec4 instanceColor; // 每个实例的颜色
attribute float instanceRadius;
void main() {
vec3 worldPos = position * instanceRadius + instancePosition;
gl_Position = projectionMatrix * viewMatrix * vec4(worldPos, 1.0);
vColor = instanceColor;
}
// WebGL2 的实例化绘制:一次 draw call 画 N 个实例
gl.vertexAttribDivisor(posLoc, 1); // 每实例前进一次(而非每顶点)
gl.vertexAttribDivisor(colorLoc, 1);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, instanceCount);
实例化的收益是「减少 draw call」。GPU 的瓶颈往往不是三角形数量,而是 draw call 次数——每次调用都有 CPU 到 GPU 的状态切换开销。把一万次 draw call 合并成一次,帧率能有数量级的提升。这是 WebGL 相对 Canvas 2D 最本质的优势。
**批量渲染(batching)**是实例化的推广:把不同几何体合并到一个大缓冲区里一次性绘制。代价是失去单个元素的独立变换能力,适合静态几何(如建筑群、地形网格)。
GPU 端计算是另一个方向:把聚合、过滤、着色计算放到片元或计算着色器里。例如用累加缓冲统计屏幕空间上每个像素桶里的点数,得到密度热力图——数据不需要传到 CPU,全程在 GPU 内完成。
6. 拾取(picking)的实现
拾取指「鼠标指向哪个元素」。Canvas 2D 里可以遍历元素做命中测试,WebGL 里数据都在 GPU,CPU 侧没有元素列表,必须用专门的技术。
三种拾取方案。
其一,颜色拾取(color picking):为每个元素分配唯一颜色,渲染到离屏缓冲,读取鼠标位置的像素颜色反查元素 ID。deck.gl 的 pickable 就是这个机制。
deck.setProps({
onClick: (info) => {
if (info.picked) console.log(info.object, info.index, info.layer.id);
},
});
// 每次点击触发一次离屏渲染,读出像素对应的索引
其二,几何拾取(CPU 侧):维护空间索引(四叉树、R 树),在 CPU 侧做命中测试。它不需要离屏渲染,但对大规模数据需要索引结构,且要考虑三维的深度(哪个元素离相机更近)。
其三,GPU 深度拾取:同时渲染颜色与深度,读取时比较深度值,返回最靠前的元素。
| 方案 | 精度 | 性能 | 适合 |
|---|---|---|---|
| 颜色拾取 | 元素级 | 每次拾取一次离屏渲染 | 通用 |
| CPU 空间索引 | 元素级 | 索引构建有成本 | 静态大数据 |
| GPU 深度拾取 | 像素级 | 需双缓冲 | 需精确深度 |
拾取的性能陷阱是「每次鼠标移动都拾取」。onHover 里做颜色拾取会在鼠标移动时持续触发离屏渲染。应当节流(如 50ms 一次)或只在点击时拾取。离屏渲染的缓冲尺寸应与主画布一致,否则坐标映射会错位。
7. LOD 与视锥裁剪
**LOD(Level of Detail)**根据元素与相机的距离选择不同精度的表示。远处的点云用稀疏采样、近处用全量;远处的模型用低面数、近处用高面数。
// 按相机距离切换 LOD 层级
function lodFor(camera, object) {
const dist = distance(camera.position, object.center);
if (dist > 5000) return object.lod2; // 最稀疏
if (dist > 1000) return object.lod1;
return object.lod0; // 全量
}
视锥裁剪(frustum culling):只渲染落在相机视野内的元素。相机视野是一个四棱锥(视锥),视锥外的元素不渲染。对点云这类大规模数据,视锥裁剪能过滤掉大部分元素——尤其当相机放大到局部时。
// 用包围盒做快速视锥测试
function inFrustum(boundingSphere, frustumPlanes) {
for (const plane of frustumPlanes) {
if (dot(plane.normal, boundingSphere.center) + plane.d > boundingSphere.radius) {
return false; // 完全在某个平面外侧,裁剪掉
}
}
return true;
}
空间索引是裁剪的前提。把数据按八叉树(三维)或四叉树(二维)组织,才能快速查询「哪些元素在视锥内」。没有空间索引的视锥裁剪退化成遍历全部元素,收益被遍历开销抵消。
LOD 与裁剪要配合使用:视锥裁剪决定「画不画」,LOD 决定「画多细」。两者叠加能把实际渲染量降到总数据量的很小比例。
8. 相机、投影与交互
三维交互的核心是相机控制。两种常用投影:
透视投影(perspective):近大远小,符合人眼直觉,适合沉浸式的三维场景。缺点是难以精确比较远近元素的尺寸。
正交投影(orthographic):远近等大,无透视变形,适合需要精确比较的场景(如三维散点图的数据分析)。
const viewState = {
longitude: 116.4, latitude: 39.9, altitude: 2000,
pitch: 45, // 俯仰角,0 为正视,90 为俯视
bearing: -20, // 方位角(旋转)
zoom: 12,
maxPitch: 85, // 限制俯仰,避免相机穿过地面
};
相机交互的三条约束。限制俯仰角(maxPitch)避免相机翻转到地面以下。限制缩放范围(minZoom/maxZoom)避免放大到无意义的程度。平滑过渡:视角切换用动画而非瞬变,让用户保持空间感。
「迷失方向」是三维交互的常见问题。用户旋转视角后不知道自己朝向哪。提供复位按钮与方位指示器(如指北针),并在切换到极端视角时给出提示。
二维与三维视图的切换是实用设计:默认给二维(可读性好),需要时切三维(看空间结构)。不要让用户一进来就面对一个旋转的三维场景——那会增加认知负担。
9. three.js 与 deck.gl 的分工
两者都是 WebGL 的封装,但抽象层次与适用场景不同。
| 维度 | deck.gl | three.js |
|---|---|---|
| 抽象层次 | 数据可视化图层 | 通用三维场景 |
| 数据模型 | 数据 + 访问器 | 网格 + 材质 + 光照 |
| 地理支持 | 内置坐标系 | 需插件 |
| 学习曲线 | 平缓(声明式) | 陡峭(需理解渲染管线) |
| 适合 | 数据驱动的可视化 | 精细的三维场景 |
deck.gl 为数据可视化而生:它内建了地理坐标系、常用的可视化图层、自动的 GPU 缓冲区管理。做「数据可视化」优先选 deck.gl——它把大量工程细节封装好了。
three.js 为通用三维而生:精细的模型、光照、材质、动画、后处理效果。做「三维场景/数字孪生/模型展示」优先选 three.js。
两者可以组合:deck.gl 提供 DeckGL 的 three.js 集成,可以在 three.js 场景里叠加 deck.gl 图层。典型用法是用 three.js 渲染底层的三维模型(建筑、地形),用 deck.gl 渲染数据图层(轨迹、热力、散点)——各取所长。
10. 二维与三维的选型边界
三维的默认代价是「可读性下降」:透视变形让距离比较失真,遮挡让部分数据不可见,旋转让读者失去方向感。只有在二维无法表达空间结构时,三维才值得这些代价。
明确的选型边界:
| 需求 | 二维 | 三维 |
|---|---|---|
| 数值比较 | 首选(条形/折线) | 不用 |
| 地理分布 | 首选(地图) | 需高程时用 |
| 空间结构(点云/体数据) | 不可 | 首选 |
| 网络关系 | 二维力导向 | 三维仅当节点有空间位置 |
| 轨迹 | 二维地图 | 三维看高程/飞行轨迹 |
「第三维承载数据」是唯一的判据。如果 Z 轴只是把二维图「立起来」,那它不承载数据,只会增加遮挡与透视失真。三维柱状图是最典型的反例——它比二维柱状图难读得多,却没有任何信息增益。
三维也可以「降维表达」:用颜色、大小、透明度编码第三维,在二维里呈现。这往往比真三维更易读。先问「第三维能否用视觉通道表达」,答案是能,就用二维。
11. 性能诊断与调优
诊断工具。Chrome DevTools 的 Performance 面板能看帧率与主线程;WebGL Inspector / Spector.js 能捕获每一帧的 draw call、着色器、状态切换。关键指标是 draw call 数与每帧的 GPU 时间。
五类常见瓶颈与对策:
| 瓶颈 | 表现 | 对策 |
|---|---|---|
| draw call 过多 | CPU 端瓶颈,帧率低 | 实例化/批渲染 |
| 顶点数过多 | GPU 顶点处理慢 | LOD、抽稀 |
| 片元过载 | 大面积极透明/光照 | 简化着色器、减少 overdraw |
| 频繁状态切换 | 帧内状态变化多 | 按材质排序绘制 |
| CPU-GPU 同步 | 读回数据阻塞 | 减少 readPixels、用异步拾取 |
overdraw 是透明渲染的隐形杀手。多个半透明图层叠加时,同一像素被反复着色。按深度从前到后绘制能让深度测试提前剔除后面的片元(early-z),减少 overdraw。
着色器要精简。片元着色器对每个像素执行,里面的一行复杂计算乘以百万像素就是显著开销。把能移到顶点着色器的计算移过去(顶点数远少于像素数),或用查找表替代实时计算。
数据上传要避免频繁。每次数据变化都重建 GPU 缓冲区的开销很大。用增量更新(只上传变化的部分)或双缓冲,避免每帧全量上传。这与实时流式图表里「只传增量」的思路一致,也是 大屏渲染性能与优化 里减少重绘的通用原则。
12. 移动端与降级策略
移动端的 GPU 能力差异巨大。低端安卓机的 GPU 性能可能只有桌面端的十分之一,显存也有限。同一份 WebGL 代码在高端机上流畅、在低端机上可能直接崩溃。
四条降级策略:
其一,检测能力并分级。用 WEBGL_debug_renderer_info 扩展读取 GPU 型号,或直接按设备类型/内存分级,选择不同的渲染参数(点数上限、LOD 层级、是否开阴影)。
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : 'unknown';
const isLowEnd = /Adreno [1-5]|Mali-[4-7]/.test(renderer);
const maxPoints = isLowEnd ? 50000 : 1000000;
其二,限制数据量。移动端主动降低采样率、减少 LOD 层级。宁可少画,也不要卡死。
其三,关闭高开销特性。移动端关闭阴影、减少透明混合、用更简单的着色器、降低像素比(devicePixelRatio 上限设为 2 或 1.5)。
其四,提供二维回退。当 WebGL 不可用(老旧设备、禁用硬件加速)或性能持续不达标时,回退到 Canvas 2D 的简化版本。降级要自动且平滑,不能让用户面对白屏。
上下文丢失(context lost)必须处理。移动端切后台、GPU 资源紧张时会丢失 WebGL 上下文。监听 webglcontextlost 与 webglcontextrestored 事件,在恢复时重建资源,否则用户回到页面会看到黑屏。
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault(); // 阻止默认行为,允许恢复
rebuildOnRestore = true;
});
canvas.addEventListener('webglcontextrestored', () => {
if (rebuildOnRestore) reinitializeGL();
});
权衡取舍
| 决策点 | 选项 A | 选项 B | 何时选 A | 何时选 B |
|---|---|---|---|---|
| 渲染后端 | Canvas 2D | WebGL | 元素 <10 万 | 元素 >10 万或实时 |
| 维度 | 二维 | 三维 | 第三维可用通道表达 | 数据有真实空间结构 |
| 库 | deck.gl | three.js | 数据可视化 | 精细三维场景 |
| 拾取 | 颜色拾取 | CPU 空间索引 | 通用、动态数据 | 静态大数据 |
| 投影 | 透视 | 正交 | 沉浸式场景 | 精确比较 |
| 移动端 | 全量 | 降级/LOD | 高端设备 | 低端设备或上下文受限 |
常见坑清单
- 二维图硬加三维——透视压缩数据、遮挡信息;第三维不承载数据就不用。
- draw call 过多——CPU 端状态切换成瓶颈;用实例化/批渲染合并。
- 频繁 readPixels 做拾取——CPU-GPU 同步阻塞;拾取节流或改 CPU 空间索引。
- 透明图层大量 overdraw——同一像素反复着色;按深度排序、开启 early-z。
- 无空间索引做视锥裁剪——遍历全量元素抵消收益;先建八叉树/四叉树。
- JSON 传百万点——解析与内存开销大;改用 TypedArray 或二进制格式。
- 着色器里做重计算——逐像素执行放大开销;能移到顶点着色器就移过去。
- 每帧全量上传数据——缓冲区重建开销大;增量更新或双缓冲。
- 不处理上下文丢失——移动端切后台回来黑屏;监听 lost/restored 事件重建。
- 低端机不降级——直接崩溃或卡死;按设备分级并保留二维回退。
小结
WebGL 与三维可视化的核心判断有两层:要不要 WebGL(元素数与实时性决定)和要不要三维(第三维是否承载数据决定)。多数需求停在第一层就够——WebGL 二维足以处理百万级数据;真正需要三维的场景是数据本身有空间结构(点云、轨迹、体数据)。
工程上的关键动作是减少 draw call、控制渲染量、做好降级:实例化与批渲染合并绘制、LOD 与视锥裁剪降低实际渲染量、移动端分级并保留二维回退。拾取与上下文丢失是 WebGL 特有的工程细节,容易被忽略却直接影响可用性。
下一步可以对照地理关系与网络图可视化,看 deck.gl 在二维地图与关系图上的用法;或回到大屏渲染性能与优化,理解渲染性能的整体框架;实时数据下的 WebGL 更新策略则是一套独立的数据流工程。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。