本文由 AI 根据一次实际排查过程总结生成,源码片段来自调试时的引擎实现,仅供记录参考。
引擎版本:LayaAir 3.4.1(2D / WebGL)
一、现象
一个用代码 Graphics 每帧重绘的特效(一圈不断扩大的圆环),功能完全正常,但 drawcall 会一路涨:
- 平时 ≈ 13,涨上去能到 40+;
- 重开、切场景都降不回来,只有刷新页面才恢复;
- 帧调试器里,同一个
program + 同一个 texture 被拆成多条独立 drawElements,还在多个 VAO 之间来回跳——典型的合批被撑散。
把项目里所有用代码 graphics 绘制的地方全部注释掉 → drawcall 恢复正常;再逐块恢复,锁定到下面这一行:
// 每帧重绘一个不断扩大的圆环:半径一帧一帧地涨
const width = this.ringWidth * (1 - u);
if (width > 0.5) {
graphics.drawCircle(cx, cy, this.maxRadius * easeOutCubic(u), null, color, width);
}
二、根因
2.1 drawCircle 的段数由「半径」决定
引擎 laya.core.js 里 Graphics._drawCircle 的实现(变量名做了重命名注释):
_drawCircle(x, y, radius, fillColor, lineColor, lineWidth) {
if (radius <= 0) return;
// 关键:段数由半径算出来
const seg = calcArcSegmentsWithScale(radius, this._tessellationScale(), 40, 5);
makeCirclePath(points, x, y, radius, seg); // 按 seg 个点拼圆
if (fillColor != null) { // 填充
writeCircleFan(fanVerts, fanIdx, x, y, radius, seg);
this._writeColorMesh(fanVerts, fanIdx, toABGR(fillColor));
}
if (lineColor != null && lineWidth > 0) { // 描边
this._appendLineMesh(points, lineWidth, true, toABGR(lineColor));
}
}
// 段数 = 半径 × 缩放 × 2π / 每段像素,再夹到 [minSegments, 2048]
function calcArcSegmentsWithScale(radius, scale, minSegments = 20, segLength = 5) {
const r = Math.max(0, radius || 0);
const s = isFinite(scale) ? Math.max(0, Math.abs(scale)) : 1;
const min = Math.max(1, minSegments | 0);
const seg = Math.max(min, Math.floor(r * s * Math.PI * 2 / (segLength || 5)));
return Math.min(2048, seg);
}
_drawCircle 传的是 (radius, scale, 40, 5),所以:
段数 = min(2048, max(40, floor(半径 × 缩放 × 2π / 5)))
半径 ≤ 31 时恒为 40 段;半径 95 时是 ≈119 段。半径连续变大,段数就连续变大,于是每帧的顶点数都不一样。
2.2 引擎的 graphics 批次池「按顶点数分桶」,而且只增不减
LayaAir 3 的 2D Graphics 不是简单地把命令丢给批次,而是给每个图形 op 分配渲染单元和一块顶点存储(store)。相关代码在 laya.webgl_2D.js:
(1)渲染单元池按 (顶点块数, 索引数) 分桶
static take(vertexCount, indexCount, ...) {
const blocks = Math.ceil(vertexCount / GRAPHICS_INFO_VERTEX_BLOCK_SIZE);
const byBlock = this._buckets.get(blocks);
const list = byBlock && byBlock.get(indexCount);
if (!list || list.length === 0) return null; // 没有同尺寸的可用单元
...
}
static recover(unit) {
if (!unit) return;
// 回收的是「整个单元」,它占的顶点块不会归还
if (this._count >= this._maxCount) return void unit.destroy();
... 按 (vertexBlockCapacity, indexCapacity) 存进 _buckets ...
}
// _maxCount = 1024
recover 保留单元占用的顶点块以便复用,所以尺寸种类越多,池子里占着的顶点块就越多。
(2)顶点存储池是全局静态的,装不下就新建,永不回收
static allocateInto(unit, vertexCount, indexCount) {
if (!unit || vertexCount <= 0 || indexCount <= 0) return false;
// 先试上次用的 store,再挨个试所有 store
... _tryAllocateInto ...
// 都塞不下:新建一个
const blocks = Math.ceil(vertexCount / GRAPHICS_INFO_VERTEX_BLOCK_SIZE);
const store = new WebGraphicsOpVIStore(Math.max(this._defaultVertexBlocks, blocks));
this._stores.push(store); // 只 push,永不移除
return true;
}
// _defaultVertexBlocks = 4096 / GRAPHICS_INFO_VERTEX_BLOCK_SIZE
// _stores = []
每个 store 会创建自己的 vertexBuffer / indexBuffer / bufferState(VAO)——这就是帧调试器里多出来的 VAO 和 Buffer。
(3)合批要求 bufferState 相同,不同 store 合不到一起
WebGraphicsBatch 合并元素时逐个检查兼容性:
isCompatible(next) {
if (!this._isTypeKeyCompatible(next)) return false;
const key = next.textureKey;
if (key !== 0 && key !== this.textureId && this.textureId !== 0) return false;
if (this.subShader !== next.subShader ||
this.bufferState !== next.geometry.bufferState || // ← 关键:bufferState 必须相同
this.clipInfo !== next.owner.getClipInfo() ||
next.owner.globalRenderData !== this.globalRenderData) {
return false;
}
...
}
batch 自己还缓存了一份「顶点缓冲 → VAO」的表,同样只在 destroy() 时清:
constructor() {
this.bufferStates = new Map(); // vertexBuffer -> bufferState(VAO)
...
}
bindBuffer(vertexBuffer) {
let state = this.bufferStates.get(vertexBuffer);
if (!state) {
state = createBufferState();
state.applyState([vertexBuffer], this.indexBuffer);
this.bufferStates.set(vertexBuffer, state);
}
return state;
}
clear() { ... } // 不清 bufferStates
destroy() { ... this.bufferStates.forEach(s => s.destroy()); ... }
2.3 串起来
flowchart TD
A["drawCircle(半径每帧变)"] --> B["引擎按半径算段数<br/>40 → 119 连续变化"]
B --> C["每帧的顶点数都不同"]
C --> D["渲染单元池按顶点数分桶<br/>每种尺寸占一批顶点块"]
D --> E["共享 store 的顶点块被占满"]
E --> F["allocateInto 未命中<br/>新建 store(新 VAO / Buffer)"]
F --> G["元素的 bufferState 不再相同"]
G --> H["isCompatible 失败<br/>合批被打散成 N 次 drawcall"]
F --> I["池是全局静态、只增不减"]
I --> J["重开 / 切场景也回收不掉<br/>只有刷新页面才恢复"]
一句话:顶点数每帧都在变 → 批次池被撑爆 → 全局池不回收 → 合批永久坏掉。
2.4 为什么其它 graphics 都没事
| 绘制方式 | 顶点数 | 会不会出事 |
| ------------------------------------------- | ------------------------------------------- | -------------------- |
| drawLine / drawLines / drawCurves | 只跟点数有关(_drawLine 固定 2 个点) | 安全 |
| 半径很小且稳定的圆 | 半径×2π/5 < 40,被兜在最小 40 段 | 安全 |
| drawRect / drawTexture | 恒定 4 顶点(quad) | 安全 |
| drawRoundRect / drawEllipse / drawPie | 段数跟圆角半径 / 半径走 | 危险 |
| 大跨度连续变化的 drawCircle | 40 → 119 连续变化 | 危险(最容易踩) |
drawLine 一看就是固定点数:
_drawLine(x, y, fromX, fromY, toX, toY, lineColor, lineWidth) {
if (lineColor == null || lineWidth <= 0) return;
points.length = 0;
points.push(fromX + x, fromY + y, toX + x, toY + y); // 永远 2 个点
this._appendLineMesh(points, lineWidth, false, toABGR(lineColor));
}
另外一个反直觉点:_tessellationScale() 取的是 Graphics 自己 transform 栈的缩放,不是节点的 scale:
_tessellationScale() {
return Math.max(this.getCurrentScaleX(), this.getCurrentScaleY());
}
所以用节点缩放做动画不会改变段数,是安全的。
三、修复
思路:别让顶点数跟着半径跑。
drawCircle 做不到固定段数,但 drawPoly(points) 的顶点数只取决于你给的点数:
_drawPoly(x, y, points, fillColor, lineColor, lineWidth) {
if (!points || points.length < 6) return;
copyOffsetPoints(pathPoints, points, x, y); // 点相对 (x, y) 偏移
this._appendPathFillAndStroke(pathPoints, fillColor, lineColor, lineWidth, true);
}
// 填充走 earcut 三角化、描边走 _appendLineMesh,顶点数都由 points.length 决定
所以自己按固定段数数好一个圆,交给 drawPoly 即可:
/** 段数写死,顶点数才恒定 */
const RING_SEGMENTS = 96;
/** 把「圆心在原点、半径 radius」的圆写成一圈点(x,y 交替)塞进 dst */
function writeCircle(dst: number[], radius: number): void {
const step = (Math.PI * 2) / RING_SEGMENTS;
for (let i = 0, j = 0; i < RING_SEGMENTS; i++, j += 2) {
const a = i * step;
dst[j] = Math.cos(a) * radius;
dst[j + 1] = Math.sin(a) * radius;
}
}
// 向外复用一个点数组,别每帧新建;点位相对圆心,drawPoly 时把圆心传成 x / y
private readonly ringPoints: number[] = new Array(RING_SEGMENTS * 2).fill(0);
// 改前:graphics.drawCircle(cx, cy, radius, null, color, width);
writeCircle(this.ringPoints, radius);
graphics.drawPoly(cx, cy, this.ringPoints, null, color, width);
改动很小、表现几乎无感(只是圆的段数固定了),代价是每帧多一点点的三角化开销,换来的是顶点数恒定、批次池不再被撑爆。
四、小结 / 自查
- 只要在
update 里改 Graphics 的几何参数,就问一句「顶点数会不会变」。 顶点数变 = 引擎按新尺寸占/建批次,全局池只涨不跌。
- 顶点数会变的 API:
drawCircle / drawEllipse / drawPie(跟半径走)、drawRoundRect(跟圆角半径走)、drawCurves(跟采样点数走)。
- 安全的:
drawRect / drawTexture(恒定 quad)、drawLine(固定 2 点)、半径很小且稳定的圆。
- 修法优先级:能固定段数就自己
drawPoly 数点 → 能用贴图 quad 就别用程序化图元(drawTexture + matrix 可旋转拉伸)→ 实在要用 drawCircle,就把半径量化成有限档位,别让它连续变。
- 排查信号:drawcall 只涨不跌、换场景也不回收、帧调试器里同
program/texture 被拆成多条 drawElements 且在多个 VAO 之间横跳——大概率是某个全局渲染池被「非重复尺寸」撑爆了。
- 定位手法:先把可疑的
graphics 绘制全部注释掉、再逐块恢复验证(printWebglOrder 可打印每帧 GL 调用),比盯着代码猜快得多。
附:关键源码索引(LayaAir 3.4.1)
| 内容 | 位置 |
| ----------------------------------------------------------------- | ---------------------------------------------------------------- |
| Graphics._drawCircle(段数由半径决定) | laya.core.js,calcArcSegmentsWithScale(radius, scale, 40, 5) |
| calcArcSegmentsWithScale | laya.core.js,min(2048, max(minSegments, floor(r·s·2π/5))) |
| Graphics._drawLine / _drawPoly / _appendPathFillAndStroke | laya.core.js |
| Graphics._tessellationScale | laya.core.js(取 Graphics 自身 transform 的缩放) |
| WebGraphicsRenderUnitPool(按尺寸分桶、回收不还块) | laya.webgl_2D.js |
| WebGraphicsOpVIStorePool(全局静态、装不下就新建、不回收) | laya.webgl_2D.js |
| WebGraphicsBatch.isCompatible(bufferState 必须相同才合批) | laya.webgl_2D.js |
| WebGraphicsIndexBatchBuffer(bufferStates 只在 destroy 时清) | laya.webgl_2D.js |
| GraphicsDefines:GRAPHICS_INFO_VERTEX_BLOCK_SIZE = 4 | laya.core.js |