第11章:性能优化与调试
11.1 章节概述
WebGL 应用的性能直接影响用户体验。本章将深入讲解如何优化 WebGL 应用的性能,以及如何调试常见问题。
本章将深入讲解:
- 性能瓶颈分析:CPU vs GPU
- Draw Call 优化:批处理、实例化
- 着色器优化:避免分支、精度选择
- 内存管理:纹理、缓冲区
- 调试工具:Spector.js、Chrome DevTools
11.2 性能分析基础
11.2.1 渲染管线瓶颈
性能瓶颈识别
WebGL 渲染涉及 CPU 和 GPU 两端
CPU 端瓶颈:
──────────────────────────────────
- 过多的 Draw Call
- JavaScript 计算量大
- 频繁的状态切换
- 数据准备和上传
表现:GPU 空闲等待,帧率受 CPU 限制
GPU 端瓶颈:
──────────────────────────────────
- 顶点处理:复杂的顶点着色器、大量顶点
- 光栅化:大量小三角形
- 片段处理:复杂着色器、高分辨率、过度绘制
- 内存带宽:大纹理、频繁纹理切换
表现:CPU 等待 GPU 完成
判断方法:
──────────────────────────────────
1. 减少渲染分辨率
- 帧率显著提升 → GPU 片段处理瓶颈
- 帧率变化不大 → 可能是 CPU 或顶点瓶颈
2. 减少 Draw Call
- 帧率提升 → CPU 瓶颈
3. 简化着色器
- 帧率提升 → 着色器瓶颈11.2.2 性能测量
javascript
/**
* 性能监控类
*/
class PerformanceMonitor {
constructor() {
this.frames = 0;
this.lastTime = performance.now();
this.fps = 0;
this.frameTime = 0;
this.frameTimes = [];
this.maxSamples = 60;
}
begin() {
this.frameStart = performance.now();
}
end() {
const now = performance.now();
const frameTime = now - this.frameStart;
this.frameTimes.push(frameTime);
if (this.frameTimes.length > this.maxSamples) {
this.frameTimes.shift();
}
this.frames++;
// 每秒更新 FPS
if (now - this.lastTime >= 1000) {
this.fps = this.frames;
this.frameTime = this.frameTimes.reduce((a, b) => a + b) / this.frameTimes.length;
this.frames = 0;
this.lastTime = now;
}
}
getStats() {
return {
fps: this.fps,
frameTime: this.frameTime.toFixed(2),
minFrameTime: Math.min(...this.frameTimes).toFixed(2),
maxFrameTime: Math.max(...this.frameTimes).toFixed(2)
};
}
}
// 使用
const perfMonitor = new PerformanceMonitor();
function render() {
perfMonitor.begin();
// 渲染代码...
perfMonitor.end();
requestAnimationFrame(render);
}
// 定期输出性能数据
setInterval(() => {
console.log(perfMonitor.getStats());
}, 1000);11.2.3 GPU 时间测量(WebGL 2.0 扩展)
javascript
// 使用 EXT_disjoint_timer_query_webgl2 扩展
const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2');
if (ext) {
const query = gl.createQuery();
gl.beginQuery(ext.TIME_ELAPSED_EXT, query);
// 绘制命令...
gl.endQuery(ext.TIME_ELAPSED_EXT);
// 稍后获取结果(异步)
function checkQuery() {
if (gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) {
const timeElapsed = gl.getQueryParameter(query, gl.QUERY_RESULT);
console.log('GPU 时间:', timeElapsed / 1000000, 'ms');
} else {
requestAnimationFrame(checkQuery);
}
}
requestAnimationFrame(checkQuery);
}11.3 Draw Call 优化
11.3.1 减少 Draw Call
Draw Call 开销
每次 Draw Call 都有 CPU 开销:
- 驱动程序验证状态
- 命令缓冲区提交
- CPU-GPU 同步
优化策略:
──────────────────────────────────
1. 批处理(Batching)
将多个小物体合并成一个大的 Draw Call
Before: After:
draw(mesh1) → draw(combinedMesh)
draw(mesh2)
draw(mesh3)
2. 实例化(Instancing)
相同几何体,不同变换
Before: After:
for 1000 次: → drawInstanced(mesh, 1000)
draw(mesh)
3. 纹理图集(Texture Atlas)
多个纹理合并成一张
避免纹理切换
4. 排序绘制顺序
按材质、纹理排序
减少状态切换11.3.2 动态批处理实现
javascript
/**
* 动态批处理器
*/
class DynamicBatcher {
constructor(gl, maxVertices = 65536) {
this.gl = gl;
this.maxVertices = maxVertices;
// 预分配缓冲区
this.positions = new Float32Array(maxVertices * 3);
this.normals = new Float32Array(maxVertices * 3);
this.uvs = new Float32Array(maxVertices * 2);
this.colors = new Float32Array(maxVertices * 4);
this.vertexCount = 0;
this.currentTexture = null;
this.positionBuffer = gl.createBuffer();
this.normalBuffer = gl.createBuffer();
this.uvBuffer = gl.createBuffer();
this.colorBuffer = gl.createBuffer();
}
begin() {
this.vertexCount = 0;
}
addMesh(mesh, transform, color = [1, 1, 1, 1]) {
// 检查是否需要 flush
if (this.vertexCount + mesh.vertexCount > this.maxVertices) {
this.flush();
}
// 检查纹理是否改变
if (mesh.texture !== this.currentTexture) {
this.flush();
this.currentTexture = mesh.texture;
}
// 添加顶点数据(应用变换)
for (let i = 0; i < mesh.vertexCount; i++) {
const vi = this.vertexCount + i;
const mi = i * 3;
// 变换位置
const pos = this.transformPoint(
mesh.positions[mi],
mesh.positions[mi + 1],
mesh.positions[mi + 2],
transform
);
this.positions[vi * 3] = pos[0];
this.positions[vi * 3 + 1] = pos[1];
this.positions[vi * 3 + 2] = pos[2];
// 复制其他属性...
}
this.vertexCount += mesh.vertexCount;
}
flush() {
if (this.vertexCount === 0) return;
const gl = this.gl;
// 上传数据
gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, this.positions.subarray(0, this.vertexCount * 3), gl.DYNAMIC_DRAW);
// ... 其他缓冲区
// 绑定纹理
if (this.currentTexture) {
gl.bindTexture(gl.TEXTURE_2D, this.currentTexture);
}
// 绘制
gl.drawArrays(gl.TRIANGLES, 0, this.vertexCount);
this.vertexCount = 0;
}
end() {
this.flush();
}
}11.3.3 按状态排序
javascript
/**
* 渲染队列排序
*/
class RenderQueue {
constructor() {
this.opaqueQueue = [];
this.transparentQueue = [];
}
add(renderable) {
if (renderable.isTransparent) {
this.transparentQueue.push(renderable);
} else {
this.opaqueQueue.push(renderable);
}
}
sort(cameraPosition) {
// 不透明物体:按材质/纹理排序(减少状态切换)
this.opaqueQueue.sort((a, b) => {
// 先按着色器排序
if (a.shader !== b.shader) {
return a.shader.id - b.shader.id;
}
// 再按纹理排序
if (a.texture !== b.texture) {
return a.texture.id - b.texture.id;
}
// 最后按距离排序(近到远,利用深度测试提前丢弃)
return a.distanceTo(cameraPosition) - b.distanceTo(cameraPosition);
});
// 透明物体:从远到近排序
this.transparentQueue.sort((a, b) => {
return b.distanceTo(cameraPosition) - a.distanceTo(cameraPosition);
});
}
render() {
let currentShader = null;
let currentTexture = null;
// 渲染不透明物体
for (const obj of this.opaqueQueue) {
// 只在必要时切换状态
if (obj.shader !== currentShader) {
obj.shader.use();
currentShader = obj.shader;
}
if (obj.texture !== currentTexture) {
obj.texture.bind();
currentTexture = obj.texture;
}
obj.draw();
}
// 渲染透明物体
// ...
}
clear() {
this.opaqueQueue.length = 0;
this.transparentQueue.length = 0;
}
}11.4 着色器优化
11.4.1 避免分支
glsl
// ❌ 不好:动态分支
if (u_lightType == 0) {
// 点光源计算
} else if (u_lightType == 1) {
// 方向光计算
} else {
// 聚光灯计算
}
// ✅ 好:使用不同的着色器程序
// 为每种光源类型编译专门的着色器
// ✅ 好:使用混合代替分支
float isPoint = float(u_lightType == 0);
float isDir = float(u_lightType == 1);
result = pointLightCalc * isPoint +
dirLightCalc * isDir +
spotLightCalc * (1.0 - isPoint - isDir);
// ✅ 好:使用 step/smoothstep
// 代替 if (x > threshold)
float factor = step(threshold, x);11.4.2 精度选择
glsl
// 根据需要选择精度
precision highp float; // 位置、变换矩阵
precision mediump float; // 颜色、UV
precision lowp float; // 颜色查找
// 在片段着色器中
// ❌ 全部使用 highp
precision highp float;
// ✅ 按需使用
precision mediump float; // 默认中精度
highp vec4 worldPos; // 位置需要高精度
lowp vec4 color; // 颜色可以低精度11.4.3 减少计算
glsl
// ❌ 在循环中重复计算
for (int i = 0; i < NUM_LIGHTS; i++) {
vec3 L = normalize(u_lights[i].position - v_worldPos); // 每次都归一化
float NdotL = dot(N, L);
// ...
}
// ✅ 预计算不变的值
vec3 N = normalize(v_normal); // 只归一化一次
for (int i = 0; i < NUM_LIGHTS; i++) {
// ...
}
// ❌ 使用 pow 计算整数幂
float x2 = pow(x, 2.0);
float x3 = pow(x, 3.0);
// ✅ 直接乘法
float x2 = x * x;
float x3 = x * x * x;
// ❌ 计算不需要的值
vec4 result;
result.rgb = texture(u_tex, uv).rgb;
result.a = 1.0; // alpha 固定为 1
// 但纹理采样读取了 4 个通道
// ✅ 使用 swizzle 或专用纹理
vec3 rgb = texture(u_tex, uv).rgb;11.4.4 向量化操作
glsl
// ❌ 标量操作
float r = color.r * 0.5;
float g = color.g * 0.5;
float b = color.b * 0.5;
// ✅ 向量操作
vec3 result = color.rgb * 0.5;
// ❌ 分别计算点积分量
float d = a.x * b.x + a.y * b.y + a.z * b.z;
// ✅ 使用内置函数
float d = dot(a, b);11.5 纹理优化
11.5.1 纹理压缩
javascript
// 检查压缩纹理支持
const s3tc = gl.getExtension('WEBGL_compressed_texture_s3tc');
const etc = gl.getExtension('WEBGL_compressed_texture_etc');
const astc = gl.getExtension('WEBGL_compressed_texture_astc');
// 使用压缩纹理
if (s3tc) {
gl.compressedTexImage2D(
gl.TEXTURE_2D,
0,
s3tc.COMPRESSED_RGBA_S3TC_DXT5_EXT,
width, height,
0,
compressedData
);
}
// 压缩格式对比
// DXT (S3TC): PC 常用,不支持移动端
// ETC: Android 支持
// PVRTC: iOS 支持
// ASTC: 高质量,新设备支持
// 使用 Basis Universal 或 KTX2 格式
// 可以转码到目标平台的格式11.5.2 Mipmap 策略
javascript
// 正确使用 Mipmap
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.generateMipmap(gl.TEXTURE_2D);
// 限制最大 mipmap 级别
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAX_LEVEL, 4);
// 各向异性过滤(提高斜视质量)
const aniso = gl.getExtension('EXT_texture_filter_anisotropic');
if (aniso) {
const max = gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT);
gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, max));
}11.5.3 纹理图集
javascript
/**
* 纹理图集管理器
*/
class TextureAtlas {
constructor(gl, width, height) {
this.gl = gl;
this.width = width;
this.height = height;
this.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
this.regions = new Map();
this.packer = new RectanglePacker(width, height);
}
add(name, image) {
const region = this.packer.pack(image.width, image.height);
if (!region) {
console.error('图集空间不足');
return null;
}
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texSubImage2D(
gl.TEXTURE_2D, 0,
region.x, region.y,
gl.RGBA, gl.UNSIGNED_BYTE,
image
);
// 存储 UV 坐标
const uvRegion = {
u1: region.x / this.width,
v1: region.y / this.height,
u2: (region.x + image.width) / this.width,
v2: (region.y + image.height) / this.height
};
this.regions.set(name, uvRegion);
return uvRegion;
}
getUV(name) {
return this.regions.get(name);
}
}11.6 内存管理
11.6.1 资源清理
javascript
/**
* 资源管理器
*/
class ResourceManager {
constructor(gl) {
this.gl = gl;
this.textures = new Map();
this.buffers = new Map();
this.programs = new Map();
this.framebuffers = new Map();
}
createTexture(name, image) {
const gl = this.gl;
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.generateMipmap(gl.TEXTURE_2D);
this.textures.set(name, texture);
return texture;
}
deleteTexture(name) {
const texture = this.textures.get(name);
if (texture) {
this.gl.deleteTexture(texture);
this.textures.delete(name);
}
}
// 清理所有资源
dispose() {
const gl = this.gl;
for (const texture of this.textures.values()) {
gl.deleteTexture(texture);
}
for (const buffer of this.buffers.values()) {
gl.deleteBuffer(buffer);
}
for (const program of this.programs.values()) {
gl.deleteProgram(program);
}
for (const fb of this.framebuffers.values()) {
gl.deleteFramebuffer(fb);
}
this.textures.clear();
this.buffers.clear();
this.programs.clear();
this.framebuffers.clear();
}
}11.6.2 对象池
javascript
/**
* 对象池 - 避免频繁创建和销毁
*/
class ObjectPool {
constructor(createFn, resetFn, initialSize = 100) {
this.createFn = createFn;
this.resetFn = resetFn;
this.pool = [];
// 预创建
for (let i = 0; i < initialSize; i++) {
this.pool.push(createFn());
}
}
acquire() {
if (this.pool.length > 0) {
return this.pool.pop();
}
return this.createFn();
}
release(obj) {
this.resetFn(obj);
this.pool.push(obj);
}
}
// 使用示例:粒子池
const particlePool = new ObjectPool(
() => ({ x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, life: 0 }),
(p) => { p.x = p.y = p.z = p.vx = p.vy = p.vz = p.life = 0; }
);
// 获取粒子
const particle = particlePool.acquire();
// 归还粒子
particlePool.release(particle);11.7 常见性能问题
11.7.1 问题清单
常见性能问题及解决方案
1. 过多的 Draw Call
──────────────────────────────────
症状:CPU 使用率高,GPU 空闲
解决:批处理、实例化、合并网格
2. 着色器编译卡顿
──────────────────────────────────
症状:首次渲染时卡顿
解决:预编译着色器、使用着色器缓存
3. 纹理上传阻塞
──────────────────────────────────
症状:加载新纹理时卡顿
解决:异步加载、渐进式加载、使用 ImageBitmap
4. 过度绘制(Overdraw)
──────────────────────────────────
症状:GPU 片段处理瓶颈
解决:从前到后渲染、遮挡剔除、LOD
5. 内存泄漏
──────────────────────────────────
症状:内存持续增长
解决:正确删除资源、使用资源管理器
6. GC 停顿
──────────────────────────────────
症状:周期性卡顿
解决:对象池、避免临时对象11.7.2 异步纹理加载
javascript
/**
* 异步纹理加载
*/
async function loadTextureAsync(gl, url) {
// 使用 ImageBitmap 进行离线解码
const response = await fetch(url);
const blob = await response.blob();
const imageBitmap = await createImageBitmap(blob);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imageBitmap);
gl.generateMipmap(gl.TEXTURE_2D);
imageBitmap.close(); // 释放 ImageBitmap
return texture;
}
// 批量加载
async function loadTextures(gl, urls) {
const promises = urls.map(url => loadTextureAsync(gl, url));
return Promise.all(promises);
}11.8 调试工具
11.8.1 Spector.js
javascript
// 使用 Spector.js 捕获帧
// 1. 安装 Chrome 扩展或通过 npm
// npm install spectorjs
import { Spector } from 'spectorjs';
const spector = new Spector();
// 捕获下一帧
spector.captureNextFrame(canvas);
// 或手动捕获
spector.startCapture(canvas, 100); // 捕获 100 个命令
// ... 渲染
spector.stopCapture();
// 查看捕获结果
spector.onCapture.add((capture) => {
console.log('捕获的命令:', capture.commands);
console.log('状态:', capture.contexts);
});11.8.2 Chrome DevTools
javascript
// 使用 console 分组输出
console.group('Frame');
console.log('Draw calls:', drawCallCount);
console.log('Triangles:', triangleCount);
console.groupEnd();
// 性能标记
performance.mark('render-start');
// 渲染代码
performance.mark('render-end');
performance.measure('render', 'render-start', 'render-end');
// 获取测量结果
const measures = performance.getEntriesByName('render');
console.log('渲染时间:', measures[measures.length - 1].duration, 'ms');11.8.3 WebGL 错误检查
javascript
/**
* WebGL 错误检查
*/
function checkGLError(gl, operation = '') {
const error = gl.getError();
if (error !== gl.NO_ERROR) {
const errorNames = {
[gl.INVALID_ENUM]: 'INVALID_ENUM',
[gl.INVALID_VALUE]: 'INVALID_VALUE',
[gl.INVALID_OPERATION]: 'INVALID_OPERATION',
[gl.INVALID_FRAMEBUFFER_OPERATION]: 'INVALID_FRAMEBUFFER_OPERATION',
[gl.OUT_OF_MEMORY]: 'OUT_OF_MEMORY',
[gl.CONTEXT_LOST_WEBGL]: 'CONTEXT_LOST_WEBGL'
};
console.error(`WebGL 错误 ${operation}: ${errorNames[error] || error}`);
return false;
}
return true;
}
// 开发模式下检查每个操作
function debugGL(gl) {
const originalFunctions = {};
for (const key of Object.keys(gl.__proto__)) {
if (typeof gl[key] === 'function') {
originalFunctions[key] = gl[key];
gl[key] = function(...args) {
const result = originalFunctions[key].apply(gl, args);
checkGLError(gl, key);
return result;
};
}
}
}11.8.4 着色器调试
glsl
// 可视化调试
// 可视化法线
gl_FragColor = vec4(v_normal * 0.5 + 0.5, 1.0);
// 可视化 UV
gl_FragColor = vec4(v_texCoord, 0.0, 1.0);
// 可视化深度
float depth = gl_FragCoord.z;
gl_FragColor = vec4(vec3(depth), 1.0);
// 可视化世界位置
gl_FragColor = vec4(fract(v_worldPos), 1.0);
// 热力图可视化(用于显示计算量)
// 根据某个值映射到颜色
vec3 heatmap(float t) {
return vec3(
clamp(1.5 - abs(4.0 * t - 3.0), 0.0, 1.0),
clamp(1.5 - abs(4.0 * t - 2.0), 0.0, 1.0),
clamp(1.5 - abs(4.0 * t - 1.0), 0.0, 1.0)
);
}11.9 本章小结
优化策略总结
| 领域 | 优化方法 |
|---|---|
| Draw Call | 批处理、实例化、排序 |
| 着色器 | 避免分支、适当精度、向量化 |
| 纹理 | 压缩、Mipmap、图集 |
| 内存 | 资源管理、对象池、及时清理 |
| 加载 | 异步加载、渐进式 |
调试工具
| 工具 | 用途 |
|---|---|
| Spector.js | 捕获和分析 WebGL 调用 |
| Chrome DevTools | 性能分析、内存检查 |
| RenderDoc | GPU 调试(需要特殊构建) |
11.10 练习题
基础练习
实现性能监控,显示 FPS 和帧时间
使用实例化渲染优化大量相同物体的绘制
进阶练习
实现简单的批处理系统
比较不同精度和优化级别的着色器性能
挑战练习
- 使用 Spector.js 分析和优化一个复杂场景
下一章预告:在第12章中,我们将通过完整的实战案例,综合运用所学知识。
文档版本:v1.0
字数统计:约 11,000 字
代码示例:45+ 个
