第二十二章:性能优化全链路

本章目标:从 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,观察:

  1. 背景层(2000 个黄色三角形)只初始化一次
  2. 前景层(移动的图片)每帧 clearRect 后重绘
  3. 在 DevTools 的 Performance 面板中录制,对比单 Canvas 版本的 CPU 占用

思考题:如果你有 3 层内容(背景网格、中间数据、最上层 tooltip),应该创建几个 Canvas?


2. Offscreen Canvas 缓存

生活类比:预制菜 vs 现炒

你去饭店吃饭:

  • 现炒:厨师从切菜开始,每道菜 15 分钟
  • 预制菜:早上批量切好、腌好,客人点了直接下锅,3 分钟出锅

Offscreen Canvas 就是图形界的"预制菜"——把复杂的图形提前画好,存起来,用的时候直接"热一下"(drawImage)。

本质是什么

Canvas 2D 的绘制指令(moveTolineToarcstrokefill)是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 允许你:

  1. 预定义路径几何(moveTo、lineTo、arc 等)
  2. 通过 ctx.translate() 移动坐标系
  3. 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,尝试:

  1. COUNT 从 1000 改到 5000,观察帧率
  2. 对比 random_shapes_cache.html(Offscreen 缓存),在放大画布时观察清晰度差异

思考题:如果你要画 10000 个大小不同的五角星,Path2D 还能直接用吗?如果不能,怎么解决?


4. Canvas Filters 的性能影响

生活类比:给照片加滤镜

你用手机拍照:

  • 原图直出:相机咔嚓一下,完事
  • 加模糊滤镜:手机要逐像素计算周围像素的加权平均,明显卡顿

Canvas 的 ctx.filter 就是这个"逐像素计算"——而且每帧都要算!

本质是什么

Canvas 2D 的 filter(如 blur(5px)grayscale(100%))是像素级后期处理。当它对 1000 个小图形生效时,浏览器实际上要做:

  1. 把每个图形绘制到临时缓冲区
  2. 对临时缓冲区应用滤镜算法(卷积、矩阵变换等)
  3. 把结果拷贝到主画布

代码对比

优化前:直接对主画布应用 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 和 Canvas filter 底层实现类似,但 Canvas filter 作用于绘制指令级别,更容易被误用。
  • 误区 2:"不用 filter 就没事了。" shadowBlurglobalCompositeOperation 也有类似的性能陷阱。

动手试一试

打开 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,同时:

  1. 在页面上尝试滚动、点击——页面完全响应
  2. 打开 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,对比帧率:

思考题:如果你要做一个"思维导图"工具,节点用矩形、连线用贝塞尔曲线、支持拖拽,你会选择哪个 API?为什么?


7. WebGL Draw Call Batching:从多次绘制到一次绘制

生活类比:快递发货

你是电商仓库管理员:

  • 方案 A:来一个订单,叫一次快递小哥。一天 3000 单,快递小哥来了 3000 次。
  • 方案 B:早上把所有订单打包好,快递小哥来一次,整车拉走。

WebGL 的 draw call 就是"叫快递小哥"。每次 draw 都有固定开销(状态切换、CPU-GPU 通信),所以减少 draw call 数量是 WebGL 优化的第一课。

本质是什么

在 WebGL 中,绘制一个物体通常需要:

  1. 绑定着色器程序(useProgram
  2. 绑定缓冲区(bindBuffer
  3. 设置 uniform 变量(颜色、位置等)
  4. 调用 gl.drawArraysgl.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 数据编码到顶点属性(如上面的 idz 分量),在顶点着色器里用算法生成变换和颜色。

动手试一试

打开 akira-graphics/performance-webgl/random-shapes-1.html(3000 次 draw call)和 random-shapes-2.html(1 次 draw call),在 Chrome DevTools 中打开 Render DocSpector.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.htmlrandom-triangles-3.html,在 Spector.js 中观察:

  1. draw call 数量差异
  2. 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.htmlpoint-primitives-circle.html,尝试修改片段着色器:

  1. 把圆改成菱形(用 abs(x) + abs(y) < r
  2. 把圆改成星星(用角度和半径的函数)

思考题:如果 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 里。" 如果数据是动态的(比如每帧从服务器接收),可以用 TextureUniform Buffer Object 批量更新。

动手试一试

打开 akira-graphics/performance-more/spots-points-batch.html,尝试:

  1. COUNT 从 200000 改到 500000,观察帧率变化
  2. 在片段着色器里把圆改成发光效果(用 1.0 - smoothstep 做径向渐变)

思考题:如果这 20 万个点不是随机分布,而是来自真实的地理坐标(经纬度),你需要在 CPU 还是 GPU 里做坐标转换?为什么?


11. 渐进式优化策略:从慢到快的决策树

生活类比:看病的流程

你去医院看病,不会一上来就做大手术:

  1. 先观察(症状是什么?)
  2. 再检查(拍片、验血)
  3. 再治疗(吃药 → 打针 → 手术)

性能优化也一样,要先测量,后优化,从成本最低的方案开始。

决策树

页面卡顿了?
├── 用 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 代替三角形吗?
    ├── [ ] 顶点着色器承担了尽可能多的计算吗?
    └── [ ] 片段着色器避免复杂分支和纹理采样了吗?

优化的黄金法则

  1. 不要过早优化:先写出正确的代码,再测量,再优化。
  2. 测量优于猜测:DevTools 的 Performance 面板、Spector.js、Chrome Tracing 是你的显微镜。
  3. 数据驱动:每次优化后记录帧率、内存、CPU 占用,用数字说话。
  4. 权衡成本: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 执行环境,没有 windowdocument。但可以通过 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:如果数据每帧都变(比如跟随鼠标),可以:

  1. 把数据上传到 gl.DYNAMIC_DRAW 缓冲区,每帧 gl.bufferSubData 更新
  2. 或者用 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+

性能优化不是背公式,而是建立系统化的分析思维

  1. 测量瓶颈在哪里
  2. 理解底层原理(CPU/GPU/内存/带宽)
  3. 选择性价比最高的方案
  4. 用数字验证效果

"过早优化是万恶之源,但从不优化是万蠢之源。" —— 改编自 Donald Knuth


延伸阅读与练习

推荐工具

  • Chrome DevTools Performance 面板:分析主线程、GPU、Paint 耗时
  • Spector.js:捕获 WebGL 帧,查看 draw call、uniform、texture
  • WebGL-Inspector:更底层的 WebGL 调用追踪

推荐练习

  1. 把本章的 random_shapes.html 从 1000 个图形优化到能流畅运行 10000 个,记录每一步的帧率变化
  2. 用 Point Sprite + Instanced Rendering 做一个"星空"效果,支持鼠标拖拽旋转视角
  3. 对比 Canvas 2D 和 WebGL 实现同一个柱状图,在 100/1000/10000 根柱子时记录性能数据

参考文件索引

主题 文件路径
图层分离 akira-graphics/performance_canvas/layers.html
基础绘制(未优化) akira-graphics/performance_canvas/random_shapes.html
Offscreen 缓存 akira-graphics/performance_canvas/random_shapes_cache.html
Path2D 复用 akira-graphics/performance_canvas/random_shapes_path2d.html
Filter 直接应用 akira-graphics/performance_canvas/random_shapes_filter.html
Filter 离屏缓冲 akira-graphics/performance_canvas/random_shapes_filter_pass.html
Web Worker akira-graphics/performance_canvas/random_shapes_worker.html + .js
Canvas 2D 画圆 akira-graphics/performance-basic/canvas-circles.html
SVG 画圆 akira-graphics/performance-basic/svg-circles.html
WebGL 画圆(Instanced) akira-graphics/performance-basic/ogl-circles.html
WebGL 多次 draw call akira-graphics/performance-webgl/random-shapes-1.html
WebGL 合批 draw call akira-graphics/performance-webgl/random-shapes-2.html
WebGL uniform 变换 akira-graphics/performance-webgl/random-triangles-2.html
WebGL instanced 变换 akira-graphics/performance-webgl/random-triangles-3.html
Point Sprite 矩形 akira-graphics/performance-more/point-primitives-rect.html
Point Sprite 圆形 akira-graphics/performance-more/point-primitives-circle.html
20 万点 Instanced akira-graphics/performance-more/spots-points-batch.html