第二十二章:性能优化全链路
本章目标:从 Canvas 2D 到 WebGL,建立完整的性能优化思维框架。学完本章,你将能独立分析渲染瓶颈,并知道"该在什么场景用什么技术"。
写在前面:为什么性能优化不是"玄学"
很多初学者觉得性能优化很玄——"为什么这段代码在他的电脑上 60fps,到我的电脑上就卡成 PPT?"
其实性能优化和工厂管理很像:
- 工厂有流水线(CPU/GPU 渲染管线)
- 有搬运工(数据从 CPU 传到 GPU)
- 有机器(GPU 着色器做实际绘制)
优化的本质只有一句话:减少不必要的搬运,让机器一次干更多的活。
本章我们会沿着这条思路,从 Canvas 2D 的"小作坊模式"一路走到 WebGL 的"工业化大生产"。每一节都有真实可运行的代码,以及具体的性能数字对比。
1. Canvas 2D 性能:图层分离(静态 vs 动态)
生活类比:舞台剧的布景与演员
想象你在看一场舞台剧:
- 布景(宫殿、森林、街道)整场戏几乎不变
- 演员(主角、配角)每一幕都在移动
如果每次演员动一下,工作人员就把整个布景拆掉重画一遍,这场戏得演到什么时候?
聪明的做法是:布景画在背景布上,演员在前景活动。两层的变动互不影响。
本质是什么
浏览器渲染 Canvas 时,clearRect + 重绘所有内容 = 每帧都画布景 + 每帧都画演员。
图层分离的核心思想:
- 静态层(背景层):画一次,之后不再清除
- 动态层(前景层):每帧只清除这一层,重绘动态内容
代码对比
优化前:单 Canvas,每帧全量重绘
<!-- 假设背景有 2000 个静态三角形,前景有 1 个移动的图片 -->
<canvas width="600" height="600" id="canvas"></canvas>
<script>
const ctx = canvas.getContext('2d');
function drawBackground() {
// 2000 个三角形——每帧都要重画!
for (let i = 0; i < 2000; i++) {
ctx.fillStyle = '#ed7';
ctx.beginPath();
// ... 画三角形
ctx.fill();
}
}
function drawForeground(t) {
const x = (t % 3000) / 3000 * canvas.width;
const y = 0.1 * canvas.height * Math.sin(3 * Math.PI * ((t % 3000) / 3000));
ctx.drawImage(img, x, y);
}
function update(t) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBackground(); // 浪费!背景根本没变
drawForeground(t); // 只有这里需要变
requestAnimationFrame(update);
}
</script>
优化后:双 Canvas 叠加
<style>
#container canvas {
position: absolute; /* 两个 canvas 叠在一起 */
}
#bg {
background-color: #000;
}
</style>
<div id="container">
<canvas width="600" height="600" id="bg"></canvas>
<canvas width="600" height="600" id="fg"></canvas>
</div>
<script>
const bgCanvas = document.querySelector('#bg');
const fgCanvas = document.querySelector('#fg');
const bgCtx = bgCanvas.getContext('2d');
const fgCtx = fgCanvas.getContext('2d');
// 背景只画一次!
function drawBackground(context, count = 2000) {
context.fillStyle = '#ed7';
const p = new Path2D('M0,0L0,10L8.66,5z');
for (let i = 0; i < count; i++) {
context.save();
context.translate(Math.random() * 600, Math.random() * 600);
context.fill(p);
context.restore();
}
}
// 前景每帧只清除自己
function drawForeground(context, t) {
const { width, height } = context.canvas;
context.clearRect(0, 0, width, height); // 只清前景!
context.save();
context.translate(0, 0.5 * height);
const p = (t % 3000) / 3000;
const x = width * p;
const y = 0.1 * height * Math.sin(3 * Math.PI * p);
context.drawImage(img, x, y);
context.restore();
}
drawBackground(bgCtx); // 初始化时画一次背景
function update(t) {
drawForeground(fgCtx, t); // 每帧只更新前景
requestAnimationFrame(update);
}
update(0);
</script>
性能对比
| 方案 | 每帧绘制调用 | 每帧清除面积 | 典型帧率(2000 静态 + 1 动态) |
|---|---|---|---|
| 单 Canvas | 2001 次绘制 | 全屏 600x600 | ~15-20 fps |
| 双 Canvas 分离 | 1 次绘制 | 仅前景 600x600 | ~60 fps |
核心收益:把 O(n) 的每帧开销变成了 O(1)。
常见误区
- 误区 1:"图层多了一定更慢。" 实际上,两个叠在一起的 Canvas 在浏览器合成层里开销极小,远小于每帧重绘 2000 个图形。
- 误区 2:"只有游戏才需要图层分离。" 数据可视化里的坐标轴、网格线都是典型的"静态层"。
动手试一试
打开 akira-graphics/performance_canvas/layers.html,观察:
- 背景层(2000 个黄色三角形)只初始化一次
- 前景层(移动的图片)每帧
clearRect后重绘 - 在 DevTools 的 Performance 面板中录制,对比单 Canvas 版本的 CPU 占用
思考题:如果你有 3 层内容(背景网格、中间数据、最上层 tooltip),应该创建几个 Canvas?
2. Offscreen Canvas 缓存
生活类比:预制菜 vs 现炒
你去饭店吃饭:
- 现炒:厨师从切菜开始,每道菜 15 分钟
- 预制菜:早上批量切好、腌好,客人点了直接下锅,3 分钟出锅
Offscreen Canvas 就是图形界的"预制菜"——把复杂的图形提前画好,存起来,用的时候直接"热一下"(drawImage)。
本质是什么
Canvas 2D 的绘制指令(moveTo、lineTo、arc、stroke、fill)是CPU 密集型的。如果每帧都要重新计算路径、描边、填充,1000 个图形就能把帧率拖垮。
Offscreen Canvas 缓存的本质:
- 一次绘制,多次复用
- 把绘制结果变成位图(像素数据)
- 后续每帧只是位图拷贝(GPU 级别的
drawImage)
代码对比
优化前:每帧重新计算路径并绘制
<script>
const ctx = canvas.getContext('2d');
const shapeTypes = [3, 4, 5, 6, 100]; // 三角形到圆形
const COUNT = 1000;
function regularShape(x, y, r, edges) {
const points = [];
const delta = 2 * Math.PI / edges;
for (let i = 0; i < edges; i++) {
const theta = i * delta;
points.push([x + r * Math.sin(theta), y + r * Math.cos(theta)]);
}
return points;
}
function drawShape(context, points) {
context.fillStyle = 'red';
context.strokeStyle = 'black';
context.lineWidth = 2;
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
context.closePath();
context.stroke();
context.fill();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < COUNT; i++) {
const type = shapeTypes[Math.floor(Math.random() * shapeTypes.length)];
const points = regularShape(
Math.random() * canvas.width,
Math.random() * canvas.height,
10,
type
);
drawShape(ctx, points); // 每帧都走完整路径计算 + 描边 + 填充!
}
requestAnimationFrame(draw);
}
draw();
</script>
优化后:Offscreen Canvas 缓存
<script>
const ctx = canvas.getContext('2d');
const shapeTypes = [3, 4, 5, 6, -1]; // -1 表示圆形
const TAU = Math.PI * 2;
function regularShape(x, y, r, edges) {
const points = [];
const delta = 2 * Math.PI / edges;
for (let i = 0; i < edges; i++) {
const theta = i * delta;
points.push([x + r * Math.sin(theta), y + r * Math.cos(theta)]);
}
return points;
}
function drawShape(context, points) {
context.lineWidth = 2;
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
context.closePath();
context.stroke();
context.fill();
}
// ========== 关键:预制菜工厂 ==========
function createCache() {
const ret = [];
for (let i = 0; i < shapeTypes.length; i++) {
// 创建一个 20x20 的离屏画布
const cacheCanvas = new OffscreenCanvas(20, 20);
const context = cacheCanvas.getContext('2d');
context.fillStyle = 'red';
context.strokeStyle = 'black';
const type = shapeTypes[i];
if (type > 0) {
const points = regularShape(10, 10, 10, type);
drawShape(context, points);
} else {
context.beginPath();
context.arc(10, 10, 10, 0, TAU);
context.stroke();
context.fill();
}
ret.push(cacheCanvas); // 存起来!
}
return ret;
}
const shapes = createCache(); // 初始化时一次性画好
const COUNT = 1000;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < COUNT; i++) {
const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
ctx.drawImage(shape, x, y); // 直接贴图!超快!
}
requestAnimationFrame(draw);
}
draw();
</script>
性能对比
| 方案 | 每帧操作 | 1000 个图形帧率 |
|---|---|---|
| 实时绘制 | 路径计算 + beginPath + stroke + fill x 1000 | ~8-12 fps |
| Offscreen 缓存 | drawImage x 1000 | ~45-60 fps |
为什么快这么多?
beginPath/stroke/fill需要 CPU 逐像素计算抗锯齿、描边宽度drawImage是 GPU 的位图拷贝,现代浏览器高度优化
常见误区
- 误区 1:"缓存一定更好。" 如果图形每帧都变形(拉伸、扭曲),缓存位图会模糊,此时不适合。
- 误区 2:"OffscreenCanvas 兼容性不行。" 现代浏览器(Chrome 69+、Firefox 105+、Safari 16.4+)已全面支持。如需兼容,可用普通
<canvas>不插入 DOM 作为替代。
动手试一试
打开 akira-graphics/performance_canvas/random_shapes.html(未缓存)和 random_shapes_cache.html(缓存),在 Chrome DevTools 的 Performance 面板中对比:
- Scripting 时间
- Painting 时间
- 帧率
思考题:如果要缓存的图形有 100 种不同的颜色,你会创建 100 个缓存,还是在缓存时用透明色、使用时再染色?
3. Path2D:可复用的路径对象
生活类比:模具 vs 手工雕刻
工厂生产塑料玩具:
- 手工雕刻:每个玩具都拿刻刀雕,一天做 10 个
- 模具:雕好一个模具,后面每个都是"注塑",一天做 10000 个
Path2D 就是 Canvas 2D 的"模具"——把路径描述封装成对象,可以反复使用。
本质是什么
在优化前的代码里,每帧都要:
context.beginPath();
context.moveTo(x0, y0);
context.lineTo(x1, y1);
// ... 很多 lineTo
context.closePath();
这些指令每帧都在重复相同的几何描述,只是位置不同。
Path2D 允许你:
- 预定义路径几何(moveTo、lineTo、arc 等)
- 通过
ctx.translate()移动坐标系 - 用
ctx.fill(path)/ctx.stroke(path)复用路径
代码对比
优化前:每帧重建路径
function drawShape(context, points) {
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
context.closePath();
context.stroke();
context.fill();
}
// 每帧调用 1000 次,每次重建路径
优化后:Path2D 复用
<script>
const ctx = canvas.getContext('2d');
const shapeTypes = [3, 4, 5, 6, -1];
const TAU = Math.PI * 2;
function regularShape(x, y, r, edges) {
const points = [];
const delta = 2 * Math.PI / edges;
for (let i = 0; i < edges; i++) {
const theta = i * delta;
points.push([x + r * Math.sin(theta), y + r * Math.cos(theta)]);
}
return points;
}
function createPath(points) {
const p = new Path2D();
p.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
p.lineTo(...points[i]);
}
p.closePath();
return p; // 返回"模具"
}
// ========== 关键:创建模具库 ==========
function createCache() {
const ret = [];
for (let i = 0; i < shapeTypes.length; i++) {
let path;
const type = shapeTypes[i];
if (type > 0) {
const points = regularShape(10, 10, 10, type);
path = createPath(points);
} else {
path = new Path2D();
path.arc(10, 10, 10, 0, TAU);
}
ret.push(path);
}
return ret;
}
const shapes = createCache();
const COUNT = 1000;
ctx.fillStyle = 'red';
ctx.strokeStyle = 'black';
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < COUNT; i++) {
const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
ctx.save();
ctx.translate(x, y); // 移动坐标系到目标位置
ctx.fill(shape); // 用模具填充
ctx.stroke(shape); // 用模具描边
ctx.restore();
}
requestAnimationFrame(draw);
}
draw();
</script>
性能对比
| 方案 | 每帧路径构建开销 | 适用场景 |
|---|---|---|
| 实时 beginPath | 高(JS 执行 + 内部路径对象创建) | 路径每帧都变 |
| Path2D 复用 | 低(只创建一次,后续直接绘制) | 路径形状固定,只有位置/颜色变化 |
Path2D vs Offscreen Canvas 怎么选?
| 维度 | Path2D | Offscreen Canvas |
|---|---|---|
| 存储内容 | 矢量路径描述 | 位图像素 |
| 放大后 | 清晰(矢量) | 模糊(位图) |
| 绘制速度 | 快(但仍需光栅化) | 极快(纯位图拷贝) |
| 内存占用 | 小 | 大(width x height x 4 字节) |
| 适用 | 需要缩放、路径复杂 | 图形小、数量多、不缩放 |
常见误区
- 误区:"Path2D 和 Offscreen Canvas 是一回事。" 完全不是。Path2D 存的是"画到哪里"的指令,Offscreen Canvas 存的是"画出来是什么样"的像素。
动手试一试
打开 akira-graphics/performance_canvas/random_shapes_path2d.html,尝试:
- 把
COUNT从 1000 改到 5000,观察帧率 - 对比
random_shapes_cache.html(Offscreen 缓存),在放大画布时观察清晰度差异
思考题:如果你要画 10000 个大小不同的五角星,Path2D 还能直接用吗?如果不能,怎么解决?
4. Canvas Filters 的性能影响
生活类比:给照片加滤镜
你用手机拍照:
- 原图直出:相机咔嚓一下,完事
- 加模糊滤镜:手机要逐像素计算周围像素的加权平均,明显卡顿
Canvas 的 ctx.filter 就是这个"逐像素计算"——而且每帧都要算!
本质是什么
Canvas 2D 的 filter(如 blur(5px)、grayscale(100%))是像素级后期处理。当它对 1000 个小图形生效时,浏览器实际上要做:
- 把每个图形绘制到临时缓冲区
- 对临时缓冲区应用滤镜算法(卷积、矩阵变换等)
- 把结果拷贝到主画布
代码对比
优化前:直接对主画布应用 filter
<script>
const ctx = canvas.getContext('2d');
const shapes = createCache(); // 假设已有 Offscreen 缓存
const COUNT = 1000;
ctx.filter = 'blur(5px)'; // 对每一次 drawImage 都生效!
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < COUNT; i++) {
const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
ctx.drawImage(shape, x, y); // 每次 drawImage 都触发一次模糊计算!
}
requestAnimationFrame(draw);
}
draw();
</script>
优化后:先绘制到离屏,再一次性 filter
<script>
const ctx = canvas.getContext('2d');
const shapes = createCache();
const COUNT = 1000;
// ========== 关键:中间层 ==========
const ofc = new OffscreenCanvas(canvas.width, canvas.height);
const octx = ofc.getContext('2d');
ctx.filter = 'blur(5px)'; // filter 只应用一次!
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
octx.clearRect(0, 0, canvas.width, canvas.height);
// 第 1 步:在无 filter 的离屏画布上快速绘制
for (let i = 0; i < COUNT; i++) {
const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
octx.drawImage(shape, x, y); // 无 filter,飞快
}
// 第 2 步:一次性把离屏内容贴到主画布,只应用一次 filter
ctx.drawImage(ofc, 0, 0);
requestAnimationFrame(draw);
}
draw();
</script>
性能对比
| 方案 | filter 应用次数 | 1000 个图形帧率 |
|---|---|---|
| 直接 filter | 1000 次(每个 drawImage) | ~3-5 fps |
| 离屏缓冲 + 一次性 filter | 1 次 | ~40-50 fps |
常见误区
- 误区 1:"filter 在 CSS 里更快。" CSS
filter和 Canvasfilter底层实现类似,但 Canvas filter 作用于绘制指令级别,更容易被误用。 - 误区 2:"不用 filter 就没事了。"
shadowBlur、globalCompositeOperation也有类似的性能陷阱。
动手试一试
打开 akira-graphics/performance_canvas/random_shapes_filter.html(直接 filter)和 random_shapes_filter_pass.html(离屏缓冲),对比帧率。
思考题:如果你需要给每个图形不同的模糊半径,离屏缓冲方案还有效吗?这种情况下你会怎么优化?
5. Web Workers:把计算搬到后台
生活类比:主厨 vs 帮厨
饭店厨房里:
- 主厨(主线程)负责炒菜、摆盘、上菜——这些事不能停
- 帮厨(Worker)负责切菜、洗菜、备料——在主厨炒菜时并行完成
如果主厨既要炒菜又要切菜,客人等菜的时间就会翻倍。
本质是什么
JavaScript 是单线程的。主线程要处理:
- 用户输入(点击、滚动)
- DOM 更新
- Canvas 绘制
- 业务逻辑计算
当计算量很大时(比如生成 10000 个随机位置、做物理模拟),主线程会被阻塞,页面卡顿。
Web Worker 允许你把纯计算任务放到另一个线程执行,主线程继续响应用户和渲染。
代码对比
优化前:主线程包揽一切
<script>
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 大量计算 + 绘制都在主线程
function heavyTaskAndDraw() {
const data = [];
for (let i = 0; i < 100000; i++) {
data.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
color: `hsl(${Math.random() * 360}, 50%, 50%)`
});
}
// 绘制...
}
</script>
优化后:Worker 负责绘制,主线程零负担
main.html:
<canvas width="600" height="600"></canvas>
<script>
const canvas = document.querySelector('canvas');
// 把 canvas 控制权转移给 Worker!
const worker = new Worker('./random_shapes_worker.js');
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage(
{ canvas: offscreen, type: 'init' },
[offscreen] // 关键:transfer,主线程不再能访问 canvas
);
</script>
random_shapes_worker.js:
const COUNT = 1000;
function regularShape(x, y, r, edges = 3) {
const points = [];
const delta = 2 * Math.PI / edges;
for (let i = 0; i < edges; i++) {
const theta = i * delta;
points.push([x + r * Math.sin(theta), y + r * Math.cos(theta)]);
}
return points;
}
function drawShape(context, points) {
context.lineWidth = 2;
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
context.closePath();
context.stroke();
context.fill();
}
const shapeTypes = [3, 4, 5, 6, -1];
const TAU = Math.PI * 2;
// Worker 里也能用 OffscreenCanvas!
function createCache() {
const ret = [];
for (let i = 0; i < shapeTypes.length; i++) {
const cacheCanvas = new OffscreenCanvas(20, 20);
const context = cacheCanvas.getContext('2d');
context.fillStyle = 'red';
context.strokeStyle = 'black';
const type = shapeTypes[i];
if (type > 0) {
const points = regularShape(10, 10, 10, type);
drawShape(context, points);
} else {
context.beginPath();
context.arc(10, 10, 10, 0, TAU);
context.stroke();
context.fill();
}
ret.push(cacheCanvas);
}
return ret;
}
function draw(ctx, shapes) {
const canvas = ctx.canvas;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < COUNT; i++) {
const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
ctx.drawImage(shape, x, y);
}
requestAnimationFrame(draw.bind(null, ctx, shapes));
}
self.addEventListener('message', (evt) => {
if (evt.data.type === 'init') {
const canvas = evt.data.canvas;
if (canvas) {
const ctx = canvas.getContext('2d');
const shapes = createCache();
draw(ctx, shapes);
}
}
});
性能对比
| 方案 | 主线程负载 | 页面交互响应 | 适用场景 |
|---|---|---|---|
| 主线程绘制 | 高(计算 + 绘制) | 可能卡顿 | 简单场景 |
| Worker 绘制 | 极低 | 完全流畅 | 复杂计算 + 大量绘制 |
常见误区
- 误区 1:"Worker 能让绘制更快。" Worker 不会加速 GPU 绘制,它只是释放主线程,让页面不卡顿。
- 误区 2:"什么都能往 Worker 里扔。" Worker 不能访问 DOM,只能做纯计算和 OffscreenCanvas 绘制。
- 误区 3:"transferControlToOffscreen 后主线程还能操作 canvas。" 一旦 transfer,主线程就永久失去这个 canvas 的控制权。
动手试一试
打开 akira-graphics/performance_canvas/random_shapes_worker.html,同时:
- 在页面上尝试滚动、点击——页面完全响应
- 打开 DevTools 的 Performance 面板,观察主线程几乎空闲
思考题:如果 Worker 需要和主线程频繁通信(比如每帧传 10000 个位置数据),通信开销会不会成为新瓶颈?怎么解决?
6. 渲染 API 对比:Canvas 2D vs WebGL vs SVG
生活类比:三种运输方式
你要把 1000 个包裹送到城里:
- SVG = 每个包裹配一个专职快递员(DOM 元素)—— 1000 个快递员,管理成本爆炸
- Canvas 2D = 一辆小货车,司机自己装卸——比快递员少,但每趟都要停 1000 次
- WebGL = 一列货运火车,一次性拉 10000 个包裹——需要铺轨道(初始化),但一旦跑起来效率极高
本质是什么
三种 API 代表了三种不同的渲染架构:
| 维度 | SVG | Canvas 2D | WebGL |
|---|---|---|---|
| 渲染模型 | 保留模式(DOM 树) | 立即模式(指令列表) | 立即模式(GPU 指令) |
| 坐标变换 | CSS/DOM 属性 | CPU 计算 + 绘制 | GPU 顶点着色器 |
| 图形数量上限 | ~3000 个元素 | ~5000-10000 个路径 | 百万级(GPU 限制) |
| 交互实现 | 天然支持(事件冒泡) | 手动命中检测 | 手动命中检测 |
| 学习曲线 | 低 | 中 | 高 |
| 适用场景 | 图标、简单图表 | 2D 游戏、复杂图表 | 3D、粒子系统、大数据可视化 |
代码对比:画 1000 个随机圆
SVG 版本
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500"></svg>
<script>
const root = document.querySelector('svg');
const COUNT = 500; // 超过 500 就开始吃力
function initCircles(count) {
for (let i = 0; i < count; i++) {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
root.appendChild(circle);
}
return [...root.querySelectorAll('circle')];
}
const circles = initCircles();
function draw() {
for (let i = 0; i < COUNT; i++) {
const circle = circles[i];
circle.setAttribute('cx', Math.random() * 500);
circle.setAttribute('cy', Math.random() * 500);
circle.setAttribute('r', 10);
circle.setAttribute('fill', `hsl(${Math.random() * 360}, 100%, 50%)`);
}
requestAnimationFrame(draw);
}
draw();
</script>
瓶颈分析:每帧 500 次 setAttribute → 浏览器要重算样式、布局、合成 → 帧率骤降。
Canvas 2D 版本
<canvas width="500" height="500"></canvas>
<script>
const ctx = canvas.getContext('2d');
function draw(count = 1000, radius = 10) {
for (let i = 0; i < count; i++) {
ctx.fillStyle = `hsl(${Math.random() * 360}, 100%, 50%)`;
ctx.beginPath();
ctx.arc(Math.random() * 500, Math.random() * 500, radius, 0, Math.PI * 2);
ctx.fill();
}
}
requestAnimationFrame(function update() {
ctx.clearRect(0, 0, 500, 500);
draw(1000, 10);
requestAnimationFrame(update);
});
</script>
瓶颈分析:1000 次 beginPath + arc + fill → CPU 路径计算 + 光栅化 → 约 30-40 fps。
WebGL 版本(OGL 库)
<canvas width="500" height="500"></canvas>
<script type="module">
import { Renderer, Program, Geometry, Transform, Mesh } from '../common/lib/ogl/index.mjs';
const renderer = new Renderer({ canvas, antialias: true, width: 500, height: 500 });
const gl = renderer.gl;
gl.clearColor(1, 1, 1, 1);
// 创建一个圆的模板几何(20 个分段)
function circleGeometry(gl, radius = 0.04, count = 3000, segments = 20) {
const tau = Math.PI * 2;
const position = new Float32Array(segments * 2 + 2);
const index = new Uint16Array(segments * 3);
const id = new Uint16Array(count);
for (let i = 0; i < segments; i++) {
const alpha = i / segments * tau;
position.set([radius * Math.cos(alpha), radius * Math.sin(alpha)], i * 2 + 2);
}
for (let i = 0; i < segments; i++) {
if (i === segments - 1) {
index.set([0, i + 1, 1], i * 3);
} else {
index.set([0, i + 1, i + 2], i * 3);
}
}
for (let i = 0; i < count; i++) id.set([i], i);
return new Geometry(gl, {
position: { data: position, size: 2 },
index: { data: index },
id: { instanced: 1, size: 1, data: id },
});
}
const geometry = circleGeometry(gl);
const vertex = `
precision highp float;
attribute vec2 position;
attribute float id;
uniform float uTime;
highp float random(vec2 co) {
highp float a = 12.9898, b = 78.233, c = 43758.5453;
highp float dt = dot(co.xy, vec2(a, b));
highp float sn = mod(dt, 3.14);
return fract(sin(sn) * c);
}
vec3 hsb2rgb(vec3 c) {
vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
return c.z * mix(vec3(1.0), rgb, c.y);
}
varying vec3 vColor;
void main() {
vec2 offset = vec2(
1.0 - 2.0 * random(vec2(id + uTime, 100000.0)),
1.0 - 2.0 * random(vec2(id + uTime, 200000.0))
);
vec3 color = vec3(random(vec2(id + uTime, 300000.0)), 1.0, 1.0);
vColor = hsb2rgb(color);
gl_Position = vec4(position * 20.0 + offset, 0, 1);
}
`;
const fragment = `
precision highp float;
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1);
}
`;
const program = new Program(gl, { vertex, fragment, uniforms: { uTime: { value: 0 } } });
const scene = new Transform();
const mesh = new Mesh(gl, { geometry, program });
mesh.setParent(scene);
function update(t) {
program.uniforms.uTime.value = t / 1000;
renderer.render({ scene });
requestAnimationFrame(update);
}
update(0);
</script>
优势分析:3000 个圆 = 1 次 draw call(instanced rendering),所有位置、颜色计算在 GPU 着色器里并行完成。
性能对比
| API | 1000 个圆 | 3000 个圆 | 10000 个圆 |
|---|---|---|---|
| SVG | ~20 fps | ~8 fps | 幻灯片 |
| Canvas 2D | ~35 fps | ~15 fps | ~5 fps |
| WebGL | 60 fps | 60 fps | ~55 fps |
常见误区
- 误区 1:"WebGL 一定比 Canvas 2D 好。" 画 10 个简单矩形时,Canvas 2D 代码量只有 WebGL 的 1/10,性能差距却几乎为零。
- 误区 2:"SVG 不能优化。" 对于静态图形,SVG 的浏览器渲染优化很好;只是动态更新时 DOM 操作成本高。
动手试一试
打开以下三个文件,把数量都调到 1000,对比帧率:
akira-graphics/performance-basic/canvas-circles.htmlakira-graphics/performance-basic/svg-circles.htmlakira-graphics/performance-basic/ogl-circles.html
思考题:如果你要做一个"思维导图"工具,节点用矩形、连线用贝塞尔曲线、支持拖拽,你会选择哪个 API?为什么?
7. WebGL Draw Call Batching:从多次绘制到一次绘制
生活类比:快递发货
你是电商仓库管理员:
- 方案 A:来一个订单,叫一次快递小哥。一天 3000 单,快递小哥来了 3000 次。
- 方案 B:早上把所有订单打包好,快递小哥来一次,整车拉走。
WebGL 的 draw call 就是"叫快递小哥"。每次 draw 都有固定开销(状态切换、CPU-GPU 通信),所以减少 draw call 数量是 WebGL 优化的第一课。
本质是什么
在 WebGL 中,绘制一个物体通常需要:
- 绑定着色器程序(
useProgram) - 绑定缓冲区(
bindBuffer) - 设置 uniform 变量(颜色、位置等)
- 调用
gl.drawArrays或gl.drawElements
如果 3000 个物体各自执行这 4 步,CPU 大部分时间都在"发指令",GPU 刚热起身就 idle 了。
Batching(合批) 的核心:把多个物体的顶点数据合并到同一个缓冲区,一次 draw 全部画完。
代码对比
优化前:3000 次 draw call
<script src="../common/lib/gl-renderer.js"></script>
<script>
const renderer = new GlRenderer(canvas);
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
const COUNT = 3000;
function render() {
for (let i = 0; i < COUNT; i++) {
// 每次循环都设置不同的 uniform
renderer.uniforms.u_color = [Math.random(), Math.random(), Math.random(), 1];
// 每次循环都创建新的几何数据
const { positions, cells } = randomShape(
2 * Math.random() - 1,
2 * Math.random() - 1,
3 + Math.floor(4 * Math.random()),
2 * Math.PI * Math.random()
);
renderer.setMeshData([{ positions, cells }]);
renderer._draw(); // 关键:这里调用了 3000 次!
}
requestAnimationFrame(render);
}
render();
</script>
优化后:1 次 draw call
<script src="../common/lib/gl-renderer.js"></script>
<script>
const renderer = new GlRenderer(canvas);
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
const COUNT = 3000;
// ========== 关键:一次性把所有顶点塞进一个大缓冲区 ==========
function createShapes(count) {
// 最多 6 边形,每个顶点 3 个 float(x, y, id)
const positions = new Float32Array(count * 6 * 3);
const cells = new Int16Array(count * 4 * 3);
positions.fill(0);
cells.fill(0);
let offset = 0;
let cellsOffset = 0;
for (let i = 0; i < count; i++) {
const edges = 3 + Math.floor(4 * Math.random());
const delta = 2 * Math.PI / edges;
for (let j = 0; j < edges; j++) {
const angle = j * delta;
// 第三个分量是 id,用于着色器里区分不同实例
positions.set(
[0.1 * Math.sin(angle), 0.1 * Math.cos(angle), i],
(offset + j) * 3
);
if (j > 0 && j < edges - 1) {
cells.set([offset, offset + j, offset + j + 1], cellsOffset);
cellsOffset += 3;
}
}
offset += edges;
}
return { positions, cells };
}
const { positions, cells } = createShapes(COUNT);
// 只设置一次 mesh 数据
renderer.setMeshData([{ positions, cells }]);
function render(t) {
renderer.uniforms.uTime = t / 1e6;
renderer.render(); // 只调用 1 次!
requestAnimationFrame(render);
}
render(0);
</script>
性能对比
| 方案 | Draw Call 次数 | 3000 个多边形帧率 |
|---|---|---|
| 逐次绘制 | 3000 | ~5-10 fps |
| 合批绘制 | 1 | 60 fps |
常见误区
- 误区 1:"合批就是简单地把数组 concat 在一起。" 实际上要注意顶点索引的偏移(
offset),否则所有图形会重叠在一起。 - 误区 2:"合批后不能单独控制每个物体的颜色/位置了。" 可以把 per-object 数据编码到顶点属性(如上面的
id和z分量),在顶点着色器里用算法生成变换和颜色。
动手试一试
打开 akira-graphics/performance-webgl/random-shapes-1.html(3000 次 draw call)和 random-shapes-2.html(1 次 draw call),在 Chrome DevTools 中打开 Render Doc 或 Spector.js 观察 draw call 数量差异。
思考题:如果 3000 个物体有不同的纹理,还能合批吗?提示:研究"纹理图集(Texture Atlas)"。
8. Uniform-based Transforms vs Instanced Arrays
生活类比:工厂流水线的两种改造方案
你的工厂要生产 1000 个不同颜色的杯子:
- 方案 A(Uniform):每生产一个杯子,停机换模具(uniform)→ 生产下一个
- 方案 B(Instanced):传送带上放 1000 个杯胚,喷漆头根据每个杯胚的"身份证"(instance ID)喷不同颜色——流水线不停
本质是什么
在 WebGL 中,给每个物体不同的变换/颜色,有两种方式:
方式 1:Uniform(每物体切换)
uniform mat3 modelMatrix; // 每个物体不同
uniform vec4 u_color; // 每个物体不同
JS 端每帧:
for (let i = 0; i < COUNT; i++) {
renderer.uniforms.modelMatrix = [...]; // 上传 9 个 float
renderer.uniforms.u_color = [...]; // 上传 4 个 float
renderer._draw(); // draw call!
}
问题:uniform 是全局状态,每次切换都要触发 draw call。
方式 2:Instanced Attributes(顶点属性 + divisor)
attribute vec2 a_vertexPosition;
attribute float id; // 每个实例不同
JS 端初始化一次:
renderer.setMeshData({
positions: [[0, 0.1], [/* ... */]], // 共享的模板几何
instanceCount: COUNT,
attributes: {
id: { data: [...new Array(COUNT).keys()], divisor: 1 }, // divisor: 1 表示每实例更新一次
},
});
优势:id 作为顶点属性,所有数据在初始化时一次性上传到 GPU。每帧只需更新一个 uTime uniform,然后 1 次 draw call 画出所有实例。
代码对比
Uniform 方案(random-triangles-2.html)
<script>
const COUNT = 3000;
// 只定义一个三角形的模板
renderer.setMeshData({
positions: [
[0, 0.1],
[0.1 * Math.sin(alpha), 0.1 * Math.cos(alpha)],
[0.1 * Math.sin(beta), 0.1 * Math.cos(beta)],
],
});
function render() {
for (let i = 0; i < COUNT; i++) {
const x = 2 * Math.random() - 1;
const y = 2 * Math.random() - 1;
const rotation = 2 * Math.PI * Math.random();
// 每帧、每个三角形都上传 uniform!
renderer.uniforms.modelMatrix = [
Math.cos(rotation), -Math.sin(rotation), 0,
Math.sin(rotation), Math.cos(rotation), 0,
x, y, 1,
];
renderer.uniforms.u_color = [Math.random(), Math.random(), Math.random(), 1];
renderer._draw(); // 3000 次!
}
requestAnimationFrame(render);
}
</script>
Instanced 方案(random-triangles-3.html)
<script>
const COUNT = 6000;
const vertex = `
attribute vec2 a_vertexPosition;
attribute float id;
uniform float uTime;
highp float random(vec2 co) {
highp float a = 12.9898;
highp float b = 78.233;
highp float c = 43758.5453;
highp float dt = dot(co.xy, vec2(a, b));
highp float sn = mod(dt, 3.14);
return fract(sin(sn) * c);
}
varying vec3 vColor;
void main() {
float t = id / 10000.0;
float alpha = 6.28 * random(vec2(uTime, 2.0 + t));
float c = cos(alpha);
float s = sin(alpha);
// 在着色器里根据 id 计算变换矩阵!
mat3 modelMatrix = mat3(
c, -s, 0,
s, c, 0,
2.0 * random(vec2(uTime, t)) - 1.0,
2.0 * random(vec2(uTime, 1.0 + t)) - 1.0, 1
);
vColor = vec3(
random(vec2(uTime, 4.0 + t)),
random(vec2(uTime, 5.0 + t)),
random(vec2(uTime, 6.0 + t))
);
vec3 pos = modelMatrix * vec3(a_vertexPosition, 1);
gl_Position = vec4(pos, 1);
}
`;
// 初始化一次
renderer.setMeshData({
positions: [[0, 0.1], [0.1 * Math.sin(alpha), 0.1 * Math.cos(alpha)], [0.1 * Math.sin(beta), 0.1 * Math.cos(beta)]],
instanceCount: COUNT, // 关键:实例数量
attributes: {
id: { data: [...new Array(COUNT).keys()], divisor: 1 }, // 关键:divisor = 1
},
});
function render(t) {
renderer.uniforms.uTime = t / 1e6; // 只更新一个 uniform!
renderer.render(); // 只调用 1 次!
requestAnimationFrame(render);
}
</script>
性能对比
| 方案 | Draw Call | CPU 每帧工作量 | 6000 个三角形帧率 |
|---|---|---|---|
| Uniform | 6000 | 上传 6000 x (9+4) 个 float | ~10 fps |
| Instanced | 1 | 上传 1 个 float (uTime) | 60 fps |
常见误区
- 误区 1:"Instanced rendering 只能画完全相同的图形。" 错!通过 instance attributes 或顶点着色器里的伪随机,每个实例可以有完全不同的位置、颜色、大小。
- 误区 2:"divisor 是什么魔法?"
divisor: 1表示这个属性每 1 个实例更新一次(而不是每顶点)。divisor: 0就是普通顶点属性。
动手试一试
打开 akira-graphics/performance-webgl/random-triangles-2.html 和 random-triangles-3.html,在 Spector.js 中观察:
- draw call 数量差异
- uniform 更新频率差异
思考题:如果每个实例需要有完全独立、不可预测的动画(比如受用户鼠标影响),instanced rendering 还能用吗?怎么把外部数据传进实例?
9. Point Sprites:用片段着色器画圆
生活类比:印章 vs 圆规
你要在纸上盖 10000 个圆点:
- 圆规:每个点都用圆规画——定位、转一圈,重复 10000 次
- 圆形印章:蘸一下印泥,"啪"一下就是一个圆——每个点只需要"定位 + 盖印"
Point Sprite 就是 GPU 的"圆形印章"。
本质是什么
传统画圆的方法(Canvas 2D 的 arc,WebGL 的三角形扇)都需要很多顶点来描述圆的轮廓。
Point Sprite 利用 WebGL 的 gl.POINTS 模式:
- 每个顶点只发一个点
(x, y) - 顶点着色器设置
gl_PointSize(点的大小,像素单位) - 片段着色器里用
gl_PointCoord判断当前像素是否在圆内
这样,一个圆 = 1 个顶点,而不是 20+ 个顶点。
代码演进
步骤 1:普通矩形 Point(point-primitives-rect.html)
// 顶点着色器
attribute vec2 a_vertexPosition;
uniform vec2 uResolution;
void main() {
gl_PointSize = 0.2 * uResolution.x; // 点的大小:120 像素
gl_Position = vec4(a_vertexPosition, 1, 1);
}
// 片段着色器
void main() {
gl_FragColor = vec4(0, 0, 1, 1); // 纯蓝色方块
}
renderer.setMeshData({
mode: renderer.gl.POINTS,
positions: [[0, 0]],
});
效果:一个蓝色方块(因为 gl.POINTS 默认是方形)。
步骤 2:圆形 Point Sprite(point-primitives-circle.html)
// 顶点着色器
attribute vec2 a_vertexPosition;
uniform vec2 uResolution;
varying vec2 vResolution;
varying vec2 vPos;
void main() {
gl_PointSize = 0.2 * uResolution.x;
vResolution = uResolution;
vPos = a_vertexPosition;
gl_Position = vec4(a_vertexPosition, 1, 1);
}
// 片段着色器
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vResolution;
varying vec2 vPos;
void main() {
// gl_FragCoord 是当前像素的屏幕坐标
vec2 st = gl_FragCoord.xy / vResolution;
st = 2.0 * st - 1.0; // 映射到 [-1, 1]
// 计算当前像素到圆心的距离
float d = distance(st, vPos);
// smoothstep 做抗锯齿边缘
d = 1.0 - smoothstep(0.195, 0.2, d);
gl_FragColor = d * vec4(0, 0, 1, 1);
}
效果:一个边缘平滑的蓝色圆!
关键原理图解
Point Sprite 的像素坐标系(gl_PointCoord):
(0, 1) -------- (1, 1)
| |
| 圆心(0.5) |
| |
(0, 0) -------- (1, 0)
每个片段着色器调用可以知道自己在这个方块里的相对位置,
从而用 distance() 判断是否落在圆内。
性能收益
| 画圆方式 | 每个圆的顶点数 | 10000 个圆的总顶点数 |
|---|---|---|
| 三角形扇(20 段) | 22 | 220,000 |
| Point Sprite | 1 | 10,000 |
常见误区
- 误区 1:"Point Sprite 大小无限制。" 实际上
gl_PointSize有上限(通常 64-256 像素,取决于 GPU)。 - 误区 2:"Point Sprite 只能画圆。" 只要能在片段着色器里用数学公式描述形状(星形、菱形、甚至文字),都可以用 Point Sprite。
动手试一试
打开 akira-graphics/performance-more/point-primitives-rect.html 和 point-primitives-circle.html,尝试修改片段着色器:
- 把圆改成菱形(用
abs(x) + abs(y) < r) - 把圆改成星星(用角度和半径的函数)
思考题:如果 Point Sprite 的大小超过了 GPU 限制,你会怎么 fallback?
10. Instanced Rendering:高效绘制 20 万个点
生活类比:复印机
你要发 20 万份传单:
- 手写 20 万份:一辈子也写不完
- 油印/复印:刻一张蜡纸,然后机器哗哗哗地印——模板只准备一次,复制 20 万次
Instanced Rendering 就是 GPU 的"复印机"。
本质是什么
前面的 Point Sprite 已经把一个圆降到了 1 个顶点。但如果要画 20 万个圆,还是有 20 万个顶点要处理。
Instanced Rendering 的终极优化:
- 模板:1 个顶点(一个 Point)
- 实例数据:20 万个
(x, y, color, bias)属性 - 1 次 draw call:GPU 内部并行处理所有实例
代码:20 万个动画圆点(spots-points-batch.html)
<script src="../common/lib/gl-renderer.js"></script>
<script>
const renderer = new GlRenderer(canvas);
const vertex = `
attribute vec2 a_vertexPosition;
attribute vec4 color;
attribute float bias;
uniform float uTime;
uniform vec2 uResolution;
varying vec4 vColor;
varying vec2 vPos;
varying vec2 vResolution;
varying float vScale;
void main() {
// 每个实例有独立的缩放动画
float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);
gl_PointSize = 0.05 * uResolution.x * scale; // 大小随动画变化
vColor = color;
vPos = a_vertexPosition;
vResolution = uResolution;
vScale = scale;
gl_Position = vec4(a_vertexPosition, 1, 1);
}
`;
const fragment = `
#ifdef GL_ES
precision highp float;
#endif
varying vec4 vColor;
varying vec2 vPos;
varying vec2 vResolution;
varying float vScale;
void main() {
// 把屏幕坐标映射到 [-1, 1]
vec2 st = gl_FragCoord.xy / vResolution;
st = 2.0 * st - vec2(1);
// 判断当前像素是否在圆内
float d = step(distance(vPos, st), 0.05 * vScale);
gl_FragColor = d * vColor;
}
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
const COUNT = 200000; // 20 万个!
function init() {
const colors = [];
const pos = [];
const bias = [];
for (let i = 0; i < COUNT; i++) {
colors.push([Math.random(), Math.random(), Math.random(), 1]);
pos.push([2 * Math.random() - 1, 2 * Math.random() - 1]);
bias.push(Math.random());
}
renderer.uniforms.uTime = 0;
renderer.uniforms.uResolution = [canvas.width, canvas.height];
renderer.setMeshData({
mode: renderer.gl.POINTS, // Point Sprite 模式
enableBlend: true, // 开启混合,圆边缘更自然
positions: pos, // 20 万个位置
attributes: {
color: { data: [...colors] }, // 20 万套颜色
bias: { data: [...bias] }, // 20 万个动画相位
},
});
}
init();
function update(t) {
renderer.uniforms.uTime = t; // 每帧只更新时间!
renderer.render(); // 1 次 draw call!
requestAnimationFrame(update);
}
update(0);
</script>
性能对比
| 方案 | 图形数量 | Draw Call | 帧率 |
|---|---|---|---|
| Canvas 2D arc | 1000 | 1000 | ~30 fps |
| WebGL 三角形扇 | 5000 | 5000 | ~20 fps |
| WebGL Point Sprite | 100000 | 1 | ~60 fps |
| WebGL Point Sprite + Instanced | 200000 | 1 | ~55 fps |
常见误区
- 误区 1:"20 万个点一定比 1000 个点慢。" 在 GPU 上,只要顶点数据能装进缓冲区,20 万个点和 1000 个点的绘制时间可能只差几倍,而不是 200 倍——因为 GPU 是并行架构。
- 误区 2:"所有数据都要放在 attributes 里。" 如果数据是动态的(比如每帧从服务器接收),可以用 Texture 或 Uniform Buffer Object 批量更新。
动手试一试
打开 akira-graphics/performance-more/spots-points-batch.html,尝试:
- 把
COUNT从 200000 改到 500000,观察帧率变化 - 在片段着色器里把圆改成发光效果(用
1.0 - smoothstep做径向渐变)
思考题:如果这 20 万个点不是随机分布,而是来自真实的地理坐标(经纬度),你需要在 CPU 还是 GPU 里做坐标转换?为什么?
11. 渐进式优化策略:从慢到快的决策树
生活类比:看病的流程
你去医院看病,不会一上来就做大手术:
- 先观察(症状是什么?)
- 再检查(拍片、验血)
- 再治疗(吃药 → 打针 → 手术)
性能优化也一样,要先测量,后优化,从成本最低的方案开始。
决策树
页面卡顿了?
├── 用 Chrome DevTools Performance 面板找到瓶颈
│ ├── Scripting 高?→ 减少 JS 计算(Web Worker、算法优化)
│ ├── Painting 高?→ 减少绘制面积(图层分离、脏矩形)
│ ├── Rendering 高?→ 减少 DOM/SVG 数量(转 Canvas)
│ └── GPU 高?→ 减少 overdraw、优化片段着色器
│
├── 确定用 Canvas 2D 还是 WebGL
│ ├── 图形 < 3000,需要丰富交互 → Canvas 2D
│ ├── 图形 > 5000,或需要 3D → WebGL
│ └── 静态图标、简单图表 → SVG
│
├── Canvas 2D 优化 checklist
│ ├── [ ] 静态内容和动态内容分离了吗?(多 Canvas)
│ ├── [ ] 重复图形用 Offscreen Canvas 缓存了吗?
│ ├── [ ] 路径用 Path2D 复用了吗?
│ ├── [ ] filter/shadow 避免每帧应用了吗?
│ └── [ ] 大数据计算放到 Worker 了吗?
│
└── WebGL 优化 checklist
├── [ ] Draw call 合批了吗?
├── [ ] Per-object 数据用 attribute/divisor 了吗?
├── [ ] 能用 Point Sprite 代替三角形吗?
├── [ ] 顶点着色器承担了尽可能多的计算吗?
└── [ ] 片段着色器避免复杂分支和纹理采样了吗?
优化的黄金法则
- 不要过早优化:先写出正确的代码,再测量,再优化。
- 测量优于猜测:DevTools 的 Performance 面板、Spector.js、Chrome Tracing 是你的显微镜。
- 数据驱动:每次优化后记录帧率、内存、CPU 占用,用数字说话。
- 权衡成本:WebGL 的代码量和维护成本是 Canvas 2D 的 5-10 倍,只在必要时升级。
真实案例分析
假设你要做一个"实时股票行情图":
- V1(原型):SVG + DOM 更新 → 100 只股票就卡
- V2(Canvas 2D):单 Canvas 实时绘制 → 1000 只流畅,但 K 线 + 均线 + 成交量 + 光标十字线互相影响
- V3(图层分离):背景网格一个 Canvas,K 线一个 Canvas,光标一个 Canvas → 光标移动不再触发全量重绘
- V4(Offscreen 缓存):把均线(计算量大但变化慢)缓存成位图 → 进一步减少绘制开销
- V5(WebGL):当股票数量到 10000+ 只时,迁移到 WebGL → 利用 GPU 并行绘制海量数据
关键洞察:不是一上来就用 WebGL,而是在每个阶段测量瓶颈,找到性价比最高的优化点。
Q&A 答疑
Q1:Offscreen Canvas 和普通 Canvas 不插入 DOM,有什么区别?
A:功能上几乎一样,但 OffscreenCanvas 可以转移到 Worker 里使用,且不需要关联 DOM,创建和销毁更轻量。如果不需要 Worker,用普通 Canvas 不插入 DOM 也完全 OK。
Q2:Path2D 和 Offscreen Canvas 缓存,哪个更快?
A:Offscreen Canvas 缓存(位图 drawImage)通常更快,因为它是 GPU 级别的位图拷贝。Path2D 快在避免了重复的路径构建指令,但绘制时仍需光栅化。代价是 Offscreen Canvas 缓存放大后会模糊。
Q3:WebGL 的 instanced rendering 兼容性如何?
A:WebGL 1.0 需要 ANGLE_instanced_arrays 扩展(覆盖率 > 98%)。WebGL 2.0 原生支持。gl-renderer、Three.js、OGL 等库都封装好了,无需担心。
Q4:Point Sprite 有大小限制,画大圆怎么办?
A:用两个三角形(TRIANGLES 模式)拼成一个正方形,在片段着色器里用同样的 distance 判断画圆。这样没有大小限制,只是多了 6 个顶点(2 个三角形) per 圆。
Q5:Worker 里能访问 DOM 吗?
A:不能。Worker 是纯粹的 JS 执行环境,没有 window、document。但可以通过 postMessage 和主线程通信,以及使用 OffscreenCanvas 做绘制。
Q6:怎么判断我的应用该用 Canvas 2D 还是 WebGL?
A:参考这个简化的判断标准:
- 图形数量 < 3000,且需要频繁交互(点击检测)→ Canvas 2D
- 图形数量 > 5000,或需要自定义着色器效果 → WebGL
- 图形完全静态,且需要 CSS 缩放 → SVG
- 3D 场景 → WebGL / WebGPU
Q7:filter 的离屏缓冲方案,对内存有影响吗?
A:有。一个 600x600 的离屏 Canvas 占用约 600 * 600 * 4 = 1.44MB 内存。对于现代设备来说微不足道,但在内存紧张的移动端要注意。如果 filter 效果可以预处理(比如用 Photoshop 做好模糊素材),那是更好的方案。
Q8:Instanced rendering 里,instance attribute 的数据怎么更新?
A:如果数据每帧都变(比如跟随鼠标),可以:
- 把数据上传到
gl.DYNAMIC_DRAW缓冲区,每帧gl.bufferSubData更新 - 或者用 Texture 存储数据,在顶点着色器里采样(更高级,适合超大数据量)
本章总结
| 优化技术 | 核心思想 | 适用场景 | 预期收益 |
|---|---|---|---|
| 图层分离 | 静态不动,动态单刷 | 背景复杂 + 前景动画 | 帧率 x3-5 |
| Offscreen 缓存 | 预绘制,后贴图 | 重复小图形大量出现 | 帧率 x3-8 |
| Path2D | 路径模具复用 | 路径固定,位置变化 | 减少 JS 开销 |
| Filter 缓冲 | 批量后处理 | 需要全局滤镜效果 | 帧率 x5-10 |
| Web Worker | 计算与渲染分离 | 复杂数据计算 | 消除卡顿 |
| API 选型 | 选对工具 | 项目初期架构决策 | 避免重构 |
| Draw Call 合批 | 减少 CPU-GPU 通信 | WebGL 多物体场景 | 帧率 x5-20 |
| Instanced Attributes | 数据进顶点缓冲 | WebGL 大量同类物体 | 帧率 x5-10 |
| Point Sprite | 1 顶点 = 1 圆 | 大量圆点/粒子 | 顶点数 / 20 |
| Instanced + Point | 终极组合 | 超大规模粒子/数据 | 60 fps @ 200k+ |
性能优化不是背公式,而是建立系统化的分析思维:
- 测量瓶颈在哪里
- 理解底层原理(CPU/GPU/内存/带宽)
- 选择性价比最高的方案
- 用数字验证效果
"过早优化是万恶之源,但从不优化是万蠢之源。" —— 改编自 Donald Knuth
延伸阅读与练习
推荐工具
- Chrome DevTools Performance 面板:分析主线程、GPU、Paint 耗时
- Spector.js:捕获 WebGL 帧,查看 draw call、uniform、texture
- WebGL-Inspector:更底层的 WebGL 调用追踪
推荐练习
- 把本章的
random_shapes.html从 1000 个图形优化到能流畅运行 10000 个,记录每一步的帧率变化 - 用 Point Sprite + Instanced Rendering 做一个"星空"效果,支持鼠标拖拽旋转视角
- 对比 Canvas 2D 和 WebGL 实现同一个柱状图,在 100/1000/10000 根柱子时记录性能数据