高性能粒子动画的工程实现:Canvas 与 WebGL 的架构选择
高性能粒子动画的工程实现Canvas 与 WebGL 的架构选择一、这个粒子效果太酷了但为什么我的手机烫得能煎蛋产品看到竞品官网的粒子背景数千个粒子在画面上漂浮、聚散、变色要求我们也来一个。第一版用 Canvas 2D 实现2000 个粒子在 PC 上跑得很丝滑。上线后移动端用户反馈打开页面 5 秒后手机开始发烫。Profiler 显示每帧 JavaScript 耗时 38ms远超 16.67ms 的帧预算全部消耗在 CPU 上的逐粒子位置计算和fillRect调用。Canvas 2D 的 API 在 CPU 上运行。2000 个粒子 × 每帧 2000 次三角函数计算 2000 次坐标变换 2000 次fillRect。而 GPU 专门为这种海量并行的简单计算而生。同样的 2000 个粒子在 WebGL 中由 GPU 的顶点着色器并行处理每帧耗时不到 2ms。二、Canvas 2D vs WebGL 的渲染路径对比flowchart LR subgraph Canvas 2D 渲染路径 (CPU) A1[JS: 计算粒子位置] -- A2[JS: 调用 fillRect/arc] A2 -- A3[CPU: 光栅化] A3 -- A4[CPU→GPU: 传输位图] A4 -- A5[GPU: 显示] end subgraph WebGL 渲染路径 (GPU) B1[JS: 更新 Uniform 数据] -- B2[JS→GPU: 传输顶点缓冲区] B2 -- B3[GPU Vertex Shader:br/并行计算每个粒子位置] B3 -- B4[GPU Fragment Shader:br/并行计算每个粒子颜色] B4 -- B5[GPU: 直接显示] end A3 -.-|2000 粒子时br/38ms/帧| C[性能瓶颈] B3 -.-|2000 粒子时br/2ms/帧| D[高帧率]关键差异在执行位置Canvas 2D 的所有计算发生在 JavaScript 主线程CPUWebGL 的计算发生在 GPU 的着色器程序中。对于每个粒子都做同样的数学运算只是数据不同这种场景GPU 的 SIMD单指令多数据流架构天然优势。三、从 Canvas 2D 迁移到 WebGL 的工程实现// particles/webgl-particle-system.ts // 基于 WebGL 的高性能粒子系统 /** * 粒子系统配置 */ interface ParticleConfig { count: number; // 粒子数量 maxSpeed: number; // 最大移动速度 minSize: number; // 最小粒子尺寸 maxSize: number; // 最大粒子尺寸 colors: [number, number, number][]; // RGB 颜色调色板 interactionRadius: number; // 粒子间交互距离连线 canvas: HTMLCanvasElement; } /** * WebGL 粒子系统 * * 核心设计将粒子状态位置、速度、颜色存储在 GPU 缓冲区中 * 每帧由顶点着色器更新位置由片段着色器渲染粒子形状。 * * JS 端只负责初始化缓冲区 → 每帧更新 Uniform时间等全局变量 * → 调用 gl.drawArrays() ——开销极小 */ class WebGLParticleSystem { private gl: WebGLRenderingContext; private program: WebGLProgram | null null; private particleCount: number; // GPU 缓冲区 private positionBuffer: WebGLBuffer | null null; // 每个粒子的位置 (x, y) private velocityBuffer: WebGLBuffer | null null; // 每个粒子的速度 (vx, vy) private colorBuffer: WebGLBuffer | null null; // 每个粒子的颜色 (r, g, b) private sizeBuffer: WebGLBuffer | null null; // 每个粒子的尺寸 // Uniform 位置 private uTimeLocation: WebGLUniformLocation | null null; private uResolutionLocation: WebGLUniformLocation | null null; private startTime: number; constructor(config: ParticleConfig) { const gl config.canvas.getContext(webgl, { alpha: true, // 透明背景 antialias: false, // 粒子场景不需要抗锯齿手动做圆形裁剪 powerPreference: high-performance }); if (!gl) { throw new Error(WebGL 不可用请检查浏览器支持); } this.gl gl; this.particleCount config.count; this.startTime performance.now(); this.initShaders(); this.initBuffers(config); this.initUniforms(); } /** * 初始化着色器程序 * * 顶点着色器根据速度 时间计算每个粒子的当前位置 * 片段着色器绘制圆形粒子通过距离判断 平滑边缘 */ private initShaders(): void { // ---- 顶点着色器 ---- // 使用 transform feedback 在 GPU 上直接更新粒子位置 // 设计意图完全避开 CPU 的逐个粒子循环计算 const vertexShaderSource attribute vec2 a_position; // 粒子初始/上一帧位置 attribute vec2 a_velocity; // 粒子速度向量 attribute vec3 a_color; // 粒子颜色 attribute float a_size; // 粒子尺寸 uniform float u_time; // 全局时间秒 uniform vec2 u_resolution; // 画布分辨率 varying vec3 v_color; varying float v_size; void main() { // 根据速度和时间更新位置 vec2 position a_position a_velocity * 0.016; // 假设 60fps每帧步进 ~16ms // 边界回弹到达边缘时反向速度 // 注意此处简化处理实际应通过 transform feedback 回写到 velocity buffer if (position.x -1.0 || position.x 1.0) { position.x clamp(position.x, -1.0, 1.0); } if (position.y -1.0 || position.y 1.0) { position.y clamp(position.y, -1.0, 1.0); } // 归一化设备坐标 (NDC) gl_Position vec4(position, 0.0, 1.0); gl_PointSize a_size; v_color a_color; v_size a_size; } .trim(); // ---- 片段着色器 ---- // 核心将方形点变为软边缘圆形 // 通过 gl_PointCoord当前像素在点内的坐标 [0,1]²计算到中心的距离 const fragmentShaderSource precision mediump float; varying vec3 v_color; varying float v_size; void main() { // 计算当前像素到点中心的距离0~1 float dist length(gl_PointCoord - vec2(0.5)); // 圆形裁剪距离 0.5 的像素丢弃 if (dist 0.5) { discard; } // 软边缘距离 0.45~0.5 之间的像素做透明度过渡 float alpha 1.0 - smoothstep(0.4, 0.5, dist); gl_FragColor vec4(v_color, alpha); } .trim(); this.program this.createProgram(vertexShaderSource, fragmentShaderSource); } /** * 编译着色器并创建 GPU 程序 */ private createProgram( vertexSource: string, fragmentSource: string ): WebGLProgram { const gl this.gl; const vertexShader this.compileShader(gl.VERTEX_SHADER, vertexSource); const fragmentShader this.compileShader(gl.FRAGMENT_SHADER, fragmentSource); const program gl.createProgram()!; gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error(Shader program link error: ${gl.getProgramInfoLog(program)}); } return program; } private compileShader(type: number, source: string): WebGLShader { const shader this.gl.createShader(type)!; this.gl.shaderSource(shader, source); this.gl.compileShader(shader); if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { throw new Error(Shader compile error: ${this.gl.getShaderInfoLog(shader)}); } return shader; } /** * 初始化 GPU 缓冲区 * * 将每个粒子的初始数据写入 GPU 显存 * - 位置随机分布在视口内 * - 速度随机方向 随机大小 * - 颜色从调色板中随机选择 * - 尺寸在 [minSize, maxSize] 范围内随机 */ private initBuffers(config: ParticleConfig): void { const count config.count; // 生成随机数据 const positions new Float32Array(count * 2); const velocities new Float32Array(count * 2); const colors new Float32Array(count * 3); const sizes new Float32Array(count); for (let i 0; i count; i) { // 位置[-1, 1] 范围NDC 坐标 positions[i * 2] Math.random() * 2 - 1; positions[i * 2 1] Math.random() * 2 - 1; // 速度随机方向 随机大小 const angle Math.random() * Math.PI * 2; const speed Math.random() * config.maxSpeed; velocities[i * 2] Math.cos(angle) * speed; velocities[i * 2 1] Math.sin(angle) * speed; // 颜色从调色板随机选择 const color config.colors[Math.floor(Math.random() * config.colors.length)]; colors[i * 3] color[0]; colors[i * 3 1] color[1]; colors[i * 3 2] color[2]; // 尺寸 sizes[i] config.minSize Math.random() * (config.maxSize - config.minSize); } // 上传到 GPU this.positionBuffer this.createBuffer(positions, a_position, 2); this.velocityBuffer this.createBuffer(velocities, a_velocity, 2); this.colorBuffer this.createBuffer(colors, a_color, 3); this.sizeBuffer this.createBuffer(sizes, a_size, 1); } /** * 创建 GPU 缓冲区并绑定到 attribute */ private createBuffer( data: Float32Array, attributeName: string, componentSize: number ): WebGLBuffer { const gl this.gl; const buffer gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); const location gl.getAttribLocation(this.program!, attributeName); gl.enableVertexAttribArray(location); gl.vertexAttribPointer(location, componentSize, gl.FLOAT, false, 0, 0); return buffer; } /** * 初始化 Uniform 变量 */ private initUniforms(): void { const gl this.gl; this.uTimeLocation gl.getUniformLocation(this.program!, u_time); this.uResolutionLocation gl.getUniformLocation(this.program!, u_resolution); gl.uniform2f( this.uResolutionLocation, gl.canvas.width, gl.canvas.height ); } /** * 渲染一帧 * * JS 端每帧只做三件事 * 1. 更新 u_time全局时间戳 * 2. 调用 gl.drawArraysGPU 并行渲染所有粒子 * 3. requestAnimationFrame 请求下一帧 */ render(): void { const gl this.gl; const elapsed (performance.now() - this.startTime) / 1000; // 秒 gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.useProgram(this.program); gl.uniform1f(this.uTimeLocation, elapsed); // 启用以点精灵渲染大小时需要的混合模式 gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // 一行调用渲染所有粒子——GPU 处理 2000 个粒子和 20 个一样快并行 gl.drawArrays(gl.POINTS, 0, this.particleCount); requestAnimationFrame(() this.render()); } /** 更新画布尺寸 */ resize(width: number, height: number): void { const gl this.gl; gl.canvas.width width; gl.canvas.height height; gl.viewport(0, 0, width, height); gl.uniform2f(this.uResolutionLocation, width, height); } }四、Canvas 2D 仍然适用的场景粒子数量 500。在这个量级上Canvas 2D 的开发效率优势远大于 WebGL 的几毫秒性能优势。ctx.arc()三行代码 vs WebGL 的着色器编译 缓冲区管理 Uniform 绑定。需要逐粒子操作复杂逻辑。如果每个粒子的行为不是规律的数学公式而是if-else 条件判断 object 查找如每个粒子有自己的状态机那么 JS 主线程上的逻辑控制远比 GPU 着色器中的条件分支清晰。需要频繁读写粒子数据。WebGL 的缓冲区数据流是 CPU→GPU 单向传输。如果需要在 JS 中读取粒子位置用于碰撞检测等需要gl.readPixels或 transform feedback——这引入了 GPU→CPU 的传输延迟约 1-2ms。五、总结Canvas 2D 和 WebGL 的选择不是好坏之分而是计算位置的选择场景选择原因粒子 500Canvas 2DAPI 简单快速出活粒子 500-5000WebGLCPU 已到瓶颈粒子 5000WebGL Instanced Rendering进一步降低 draw call复杂逐粒子逻辑Canvas 2D Worker把计算移到 Worker 线程粒子 光照/后处理WebGL2 / WebGPU需要 fragment shader 能力衡量标准很直接在目标设备上跑 Profiler如果 JS 耗时 10ms/帧Canvas 2D 足够超过这个标准就是 WebGL 该登场的时候。