跳到主要内容

绘制调用与合批

一次 draw* 会把当前绑在上下文上的整条管线踢给 GPU。CPU 侧真正贵的往往是 调用次数和状态切换,不是三角形个数本身。万级相同材质的点,应该是 1 次实例化,不是 1 万次 drawArrays

1. 成本从哪来

操作为什么贵替代
每物体一次 drawElements驱动验证 + 命令提交合批 / draw*Instanced
每 draw 换 program / VAO / 纹理状态切换打满 CPU按 shader → 纹理 → mesh 排序
每帧 bufferData 整包分配 + 上传静态一次;动态 bufferSubData 脏区
每帧 texImage2D 大图带宽图集、只更新脏 atlas 矩形
热路径 getError隐式 flush调试开关
全屏未合批半透明过绘不透明先画,透明单独排

WebGL2 是全局状态机:漏 bind 的失败形态是画面错,不是抛异常。WebGPU 把大部分状态收进 pipeline / bind group,合批键变成 pipeline + bind group 布局,不要把「全局 gl」思维带过去。

2. 合批键与实例化

WebGL2 里实例化是 核心drawArraysInstanced / drawElementsInstanced / vertexAttribDivisor),不要再查 ANGLE_instanced_arrays。WebGL1 降级才需要扩展。

export interface BatchKey {
program: WebGLProgram;
vao: WebGLVertexArrayObject;
texture: WebGLTexture | null;
}

export function drawOpaqueBatches(
gl: WebGL2RenderingContext,
keys: readonly BatchKey[],
instanceCounts: readonly number[],
): void {
let prev: BatchKey | undefined;
for (let i = 0; i < keys.length; i += 1) {
const key = keys[i];
const count = instanceCounts[i] ?? 0;
if (count <= 0) continue;
if (prev?.program !== key.program) gl.useProgram(key.program);
if (prev?.vao !== key.vao) gl.bindVertexArray(key.vao);
if (prev?.texture !== key.texture) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, key.texture);
}
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
prev = key;
}
gl.bindVertexArray(null);
}

适用:监控散点、粒子、瓦片上重复 mesh、图标图集。CPU 场景仍按对象存,GPU 侧把同 key 的 transform 打进 instance buffer。
不适用:每物体不同 shader 变体还硬合批——先砍变体数量;深度排序必须的透明物体不能随便打乱。

静态几何合并成更少 mesh(索引拼接)是另一档,改的是文档。实例化改的是 同一 mesh 的多次出现。两者都要做时,先实例化,再考虑几何合并。对象可点、可复制文本,应留在 SVG / DOM,不要为每个标签开一次 draw。

3. EXT_disjoint_timer_query 谨慎

export function tryGpuTimer(gl: WebGL2RenderingContext): {
begin: () => void;
end: () => void;
readNs: () => number | undefined;
} | null {
const ext = gl.getExtension("EXT_disjoint_timer_query_webgl2");
if (!ext) return null;
const query = gl.createQuery();
if (!query) return null;
let pending = false;
return {
begin: () => {
if (pending) return;
gl.beginQuery(ext.TIME_ELAPSED_EXT, query);
},
end: () => {
gl.endQuery(ext.TIME_ELAPSED_EXT);
pending = true;
},
readNs: () => {
if (!gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) return undefined;
pending = false;
if (gl.getParameter(ext.GPU_DISJOINT_EXT)) return undefined;
return gl.getQueryParameter(query, gl.QUERY_RESULT) as number;
},
};
}
  • 扩展经常 没有(尤其移动端)。没有就不要测 GPU 时间。
  • QUERY_RESULT 在 not available 时读会 阻塞,变成你要避免的同步点。隔帧查 AVAILABLE
  • GPU_DISJOINT_EXT 为 true:频率变化 / 热节流,这次纳秒扔掉。
  • 查询本身会打乱流水线。用来做离线 profile 或低频采样,不要用来决定本帧画不画、不要当 vsync。

WebGPU timestamp-queryadapter.features.has("timestamp-query")。同样:探测、双缓冲 query set、热路径默认关。

4. 怎么看,而不是怎么猜

  • 统计每帧:drawCountprogramBindstextureBindsuploadedBytes。先打这些计数,再谈优化。
  • Chrome GPU 过程 / Spector.js 看是否每物体一次 draw。
  • 高刷屏上「感觉 60」不够。输入延迟和 drawCount 对不上时,合批优先于微优化 shader。

5. 失败形态

症状原因
CPU 高、GPU 闲成千上万次 draw / 状态切换
合批后透明错乱打乱了需要回到前的顺序
实例化全黑divisor 没设、instance buffer 没绑进 VAO
timer 一开就卡每帧同步读 QUERY_RESULT
移动端突然掉帧热节流;disjoint 被当成「算法变慢」

权威资料

核对日期:2026-08-26