跳转到内容

性能剖析与诊断

Estella 把诊断能力做成普通的 SDK 表面:系统可以读取的 Stats 资源游戏内 浮层、带可插拔输出的结构化日志器、记录每个 draw call(并能重放到其中任意一个) 的帧捕获GL 错误检查、GPU 资源驻留统计,以及每个 App 一份的子系统健康 注册表。本篇逐一讲清。

添加 statsPlugin 后,引擎每帧把性能数据收集进 Stats 资源(在 Last 调度阶段, 一切系统跑完之后):

import { statsPlugin } from 'esengine';
app.addPlugin(statsPlugin);

statsPlugin 是一个带默认配置的现成实例。需要选项时,自己构造 StatsPlugin:

import { StatsPlugin } from 'esengine';
app.addPlugin(new StatsPlugin({ overlay: true, position: 'top-right' }));
StatsPluginOptions 类型 默认 说明
overlay boolean true 显示 DOM 浮层(没有 document 的环境会自动跳过)。
position StatsPosition 'bottom-left' 浮层角落:'top-left' / 'top-right' / 'bottom-left' / 'bottom-right'
container HTMLElement document.body 浮层面板挂载到的元素。

该插件还会调用 app.enableStats(),开启逐系统、逐阶段的耗时采集(默认关闭, 所以正式发布的游戏不为此付费)。

Stats 是普通资源——用 Res(Stats) 注入:

import { defineSystem, addSystem, Res, Stats } from 'esengine';
const watchPerf = defineSystem([Res(Stats)], (stats) => {
if (stats.fps > 0 && stats.fps < 30) {
console.warn(`slow frame: ${stats.frameTimeMs.toFixed(1)}ms, ${stats.drawCalls} draw calls`);
}
});
addSystem(watchPerf);

FrameStats 字段(FPS 与帧时间是 60 帧滑动窗口的平均;渲染计数是当前帧的):

FrameStats 字段 类型 说明
fps number 每秒帧数,按最近 60 帧平均。
frameTimeMs number 同一窗口内的平均帧时间(毫秒)。
entityCount number 世界中的存活实体数。
systemTimings Map<string, number> 本帧每个系统的 CPU 毫秒数,按系统名索引。
phaseTimings Map<string, number> 本帧每个调度阶段的 CPU 毫秒数。
drawCalls number 本帧发出的 GPU draw call 数。
triangles number 本帧提交的三角形数。
sprites number 本帧渲染的精灵数。
text number 本帧渲染的文本实体数。
spine number 本帧渲染的 Spine 骨骼数。
meshes number 本帧渲染的网格数。
culled number 本帧被剔除(未提交)的实体数。

overlay: true(默认)时,插件渲染一个固定位置的小等宽字体面板:FPS + 帧时间、 draw call / 三角形 / 精灵 / 剔除数、实体数,以及按最差帧排名的前 5 个系统—— 每行显示 平均 / 最大 ms。面板最多每 500 毫秒重绘一次,期间持续累积系统耗时, 因此开销极小,而单帧尖峰仍会出现在 max 列里。

也可以自己驱动 StatsOverlay——比如绑到调试热键上:

import { StatsOverlay, defineSystem, addSystem, Res, Stats } from 'esengine';
const overlay = new StatsOverlay(document.body, 'top-right');
const feedOverlay = defineSystem([Res(Stats)], (stats) => {
overlay.update(stats);
});
addSystem(feedOverlay);
// overlay.hide() / overlay.show() 切换显示,overlay.dispose() 移除。

FrameStats 里的渲染计数来自 Renderer.getStats(),也可以直接调用(不需要 stats 插件)——它返回一个 RenderStats,同样是那七个字段:drawCallstrianglesspritestextspinemeshesculled

import { Renderer } from 'esengine';
const rs = Renderer.getStats();
console.log(`${rs.drawCalls} draw calls, ${rs.triangles} triangles`);

插件背后有两个小工具类,均已导出供自定义工具使用:

  • StatsCollectorfps / frameTimeMs 背后的 60 帧滑动窗口。用 pushFrame(deltaSeconds) 喂入帧间隔,读 getFps() / getFrameTimeMs(); reset() 清空窗口。
  • FrameHistoryFrameSnapshot 的环形缓冲(默认容量 300,60 fps 下约 5 秒),用于绘制帧时间曲线。push(frameTimeMs, phaseTimings, systemTimings?) 会深拷贝两个 Map,快照保持有效;getLatest() 返回最新快照,getAll() 返回从旧到 新的整个窗口,count / reset() 顾名思义。
FrameSnapshot 字段 说明
frameTimeMs 该帧的总 CPU 时间。
phaseTimings 逐阶段毫秒数(push 时拷贝)。
systemTimings 逐系统毫秒数(push 时拷贝)。

从发行版游戏录制捕获 —— ProfileRecorder

Section titled “从发行版游戏录制捕获 —— ProfileRecorder”

编辑器的性能分析器只存在于编辑器连着的地方,而掉到 40 fps 通常是掉在玩家的设备上。 ProfileRecorder 把运行中游戏的帧录制成一份 .esprof 捕获,用编辑器的 Profiler 面板打开——同一棵树、同样的行、同样的合计。

import { ProfileRecorder } from 'esengine';
const recorder = new ProfileRecorder(app, {
maxFrames: 1800, // 60Hz 下约 30 秒,超出后丢最旧的
source: { platform: 'wechat', label: 'Redmi Note 12 · boss 战' },
});
recorder.start();
// …… 把卡顿的那一段玩一遍 ……
recorder.stop();
const capture = recorder.take(); // 一个普通对象
ProfileRecorderOptions 类型 默认值 说明
maxFrames number 1800 保留多少帧,超出后丢弃最旧的。
budgetMs number 1000 / 60 读者据以判断这份捕获的帧预算。
source CaptureSource {} 设备、构建、场景——任何日后能认出这份捕获的信息。

start() 会打开它要读的插桩——JS 侧的统计和引擎 C++ 侧的性能分析都要开,因为缺了后者的捕获 看起来会像“引擎不花时间”,而不是“这部分没被测量”——stop() 再把它们放下。 在你启动它之前,不录制任何东西,也不测量任何东西。

在 web 构建里保存它就是普通的 DOM 操作:

const blob = new Blob([JSON.stringify(recorder.take())], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'boss-fight.esprof';
a.click();

然后在编辑器里用 Profiler ▸ 打开… 打开它。parseProfileCapture 遇到不是捕获的文件会 带理由拒绝(不是 JSON、没有版本、帧不是帧、版本比编辑器能读的更新)而不是抛异常; summarizeCapture 就是实时视图用的那个函数——所以一份在手机上录的文件和编辑器自己的最近一秒, 不可能从同一批帧算出不同的帧率。

自己观察每一帧 —— app.onFrameEnd

Section titled “自己观察每一帧 —— app.onFrameEnd”

录制器只是 app.onFrameEnd(fn) 的一个普通消费者。这个回调在每帧的系统跑完、耗时已成定局之后 触发一次。它是广播,所以你自己的帧预算告警可以和录制器一起看:

const off = app.onFrameEnd((dtMs) => {
if (dtMs > 33) console.warn(`长帧:${dtMs.toFixed(1)}ms`);
});

它返回一个解除函数。配合 app.getFrameCosts() 就能拿到刚结束那一帧的逐系统、逐 scope 开销。

SDK 自身的诊断信息走结构化日志器而不是裸 console.*,你的游戏代码可以用同一条 通道。每条消息有级别、自由字符串的类别('physics''net'、你自己的 'gameplay'……)、消息文本,以及可选的结构化 data:

import { log, setLogLevel, LogLevel } from 'esengine';
setLogLevel(LogLevel.Debug); // default is LogLevel.Info
log.debug('gameplay', 'Wave spawned', { wave: 3, enemies: 12 });
log.info('save', 'Game saved');
log.warn('net', 'High latency', { rttMs: 240 });
log.error('boot', 'Asset manifest failed', err); // Error keeps its stack

LogLevelDebug < Info < Warn < Error;setLogLevel 设置最低级别——低于它 的消息在到达任何 handler 之前就被丢弃。独立的 debug / info / warn / error 函数同样已导出,转发到同一个默认日志器;需要 Logger 实例本身时用 getLogger()

默认装有一个控制台 handler:它格式化为 [time] [LEVEL] [category] message,按级别 选择对应的 console 方法,并把 Error 数据作为独立参数传入,让浏览器原生渲染堆栈。

LogHandler 以结构化 LogEntry 的形式收到每条被接受的消息——这是游戏内 控制台、文件写入器、崩溃报告面包屑的挂接点。handler 抛异常会被捕获并上报, 绝不拖垮应用:

import { getLogger, LogLevel, type LogEntry, type LogHandler } from 'esengine';
class Breadcrumbs implements LogHandler {
entries: LogEntry[] = [];
handle(entry: LogEntry): void {
if (entry.level >= LogLevel.Warn) {
this.entries.push(entry);
if (this.entries.length > 100) this.entries.shift();
}
}
}
const crumbs = new Breadcrumbs();
getLogger().addHandler(crumbs);
// getLogger().removeHandler(crumbs) to detach,
// getLogger().clearHandlers() to remove every handler (console one included).
LogEntry 字段 类型 说明
timestamp number 记录时刻的 Date.now()(epoch 毫秒)。
level LogLevel Debug / Info / Warn / Error
category string 调用方传入的通道字符串。
message string 消息文本。
data unknown 可选负载(对象、Error……)。

上面所有内容都假设你在看着。而游戏发布之后你并不在:那个会打印错误的控制台在玩家手机上。Diagnostics 资源负责收集出了什么问题,并交给一个由你指定的去处。

引擎绝不替你选择这个去处。 没有默认端点、没有内置服务商,也不会自己开任何连接。堆栈里带着文件路径,消息里带着被拼接进去的一切,所以这些数据去哪儿是你的决定,不是我们的。没有安装 sink 时插件照常完整工作——事件照样聚合、你随时可读——只是什么都不会离开设备。

该插件默认已安装,无需任何配置即可使用:

import { Diagnostics, type DiagnosticEvent } from 'esengine';
const diagnostics = app.getResource(Diagnostics);
diagnostics.setSink((events: readonly DiagnosticEvent[]) => {
// 收到的是带计数的「不同问题」,不是每次发生调用一次。
void fetch('https://your-service.example/errors', {
method: 'POST',
body: JSON.stringify({ build: '1.4.2', events }),
});
});

你的 sink 不能抛异常(抛了会被吞掉),也不该阻塞——在里面 await 一次网络往返,等于拿一份错误报告换了一次卡顿。请自己攒批再发。

类别 来自哪里
engine 引擎作为错误记录的一切 —— 某个 system 在调度中抛异常、某个资产加载不了、某个子系统拒绝启动。
unhandled 没人接住、直接到达宿主的错误:window.onerror / unhandledrejectionwx.onError / wx.onUnhandledRejection。通常是 system 之外的游戏代码——回调、promise、定时器。
context-lost GPU 收回了渲染上下文。之后的帧什么也画不出来,而且不会抛任何错误——这是唯一能知道它发生过的途径。
memory 系统提示内存不足:它在进程被杀掉之前到达,而那次崩溃本身是永远发不出报告的。
game 你自己调用 report 上报的内容。

engine 这一半不需要任何平台支持——它监听的正是上一节所说的那条日志广播。所以安装 Diagnostics 的 sink 不会从 app.onErrorapp.onSystemError 或你自己的 LogHandler 那里拿走任何东西,它们照常工作。其余类别则取决于宿主有没有对应信号:

网页 微信 / 小游戏 原生(Android / iOS)
unhandled 需要壳层接入
memory 需要壳层接入
context-lost 没有 —— 小游戏的 canvas 不是 DOM 元素,也没有厂商 API 报告它 需要壳层接入

警告默认不收集;排查具体问题时给插件传 captureLevel: LogLevel.Warn

是「不同的问题」,不是「每次发生」

Section titled “是「不同的问题」,不是「每次发生」”

一个抛异常的 system 会每帧都抛——每秒六十条一模一样的报告,而且是所有玩家同时。逐条发出去那不叫遥测,那是你的游戏自己造出来的事故。所以单位是带计数的不同问题:

diagnostics.setSink((events) => {
for (const e of events) {
console.log(`${e.kind} ${e.source ?? ''}: ${e.message} ×${e.count}`);
// engine physics: the world could not step ×1842
}
});

当类别、来源、消息和抛出位置都相同时,两次发生算同一个问题。用完整堆栈会把一个 bug 按调用者拆成十个;只用消息又会把两个不相干的 bug 并成一个。消息里的数字会被归一化,所以 Entity 41 has no Transform 只记一个问题,而不是每个实体一条。

DiagnosticEvent 字段 类型 说明
kind DiagnosticKind 上面五种之一。
id string 跨重复出现的稳定身份。
message string 上报时的消息。
source string? 日志分类,或 system 的名字。
stack string? 抛出物带堆栈时存在。
count number 自首次出现以来的发生次数。
firstAt / lastAt number epoch 毫秒,首次与最近一次。
context object? 上报方附带的数据;以最新的为准。
diagnostics.report({
kind: 'game',
message: 'Cloud save rejected the payload',
context: { slot: 3, bytes: 41_233 },
});
try { risky(); } catch (err) {
diagnostics.reportError('game', err, 'shop');
}

不要把能识别玩家身份的数据放进 context,理由和引擎不替你选端点是同一个:它最终会去到你的 sink 送达的任何地方。

import { DiagnosticsPlugin } from 'esengine';
app.addPlugin(new DiagnosticsPlugin({
maxDistinct: 64, // 同时跟踪多少个不同问题
flushIntervalSec: 10, // 每隔多少秒把一批交给 sink
}));

达到 maxDistinct 后,新问题会被丢弃而已知问题继续计数,diagnostics.dropped 告诉你丢了多少——它非零就意味着这批数据并不完整,sink 应当如实说明,而不是让它读起来像全貌。被保留的是最早出现的那些:游戏垮掉时是连锁垮的,第一个失败往往解释了后面五十个。

flush 走引擎时钟而非定时器,因此后台标签页或被挂起的小游戏不会从一个根本没在运行的游戏里往外发。flush() 可立即发送(比如切关之前),关闭时也会自动发一次。

帧捕获 — 每个 draw call 都有解释

Section titled “帧捕获 — 每个 draw call 都有解释”

当“这帧为什么是 40 个 draw call?“需要答案时,捕获一帧。先武装捕获,让一帧渲染 完,然后读回每个 draw call 的记录——它画了什么、绑了哪个贴图/材质/着色器和 什么状态,以及最关键的:上一个批次为什么被打断:

import { Renderer, RenderType, BatchBreak } from 'esengine';
Renderer.captureNextFrame(); // arm: the NEXT rendered frame records
// …one frame later:
if (Renderer.hasCapturedData()) {
const capture = Renderer.getCapturedData(); // FrameCaptureData | null
for (const dc of capture!.drawCalls) {
console.log(
`#${dc.index} ${RenderType[dc.type]} tex=${dc.textureId} ` +
`tris=${dc.triangleCount} entities=${dc.entityCount} ` +
`break=${BatchBreak[dc.breakReason]}`
);
}
console.log(`${capture!.cameraCount} camera pass(es)`);
}

FrameCaptureData{ drawCalls: DrawCallInfo[], cameraCount }。每个 DrawCallInfo:

DrawCallInfo 字段 说明
index 该 draw call 在帧内的序号(提交顺序)。
cameraIndex 由哪个相机 pass 发出。
stage 渲染阶段(RenderStage:Background / Opaque / Transparent / Overlay)。
type 绘制的内容类型(RenderType,见下)。
blendMode 生效的混合模式 id。
textureId / materialId / shaderId 该调用绑定的 GPU 资源。
vertexCount / triangleCount 提交的几何量。
entityCount / entityOffset / entities 被批进该调用的实体 id。
layer 批次的渲染层。
breakReason 该调用为何没有并入前一个(BatchBreak,见下)。
scissorX/Y/W/HscissorEnabled 裁剪矩形状态(UI 遮罩)。
stencilWrite / stencilTest / stencilRef 模板状态(遮罩写入/读取方)。
textureSlotUsage 批次占用的纹理槽数。

RenderType 说明一个调用画了什么:SpriteSpineMeshExternalMeshTextParticleShapeUIElement

BatchBreak 就是合批的故事——每个值都点名了让这个调用无法并入前一个的那次状态 变化,也就是想合批更好该修什么。每个成员都是合批判定自身的一个分支,所以这张表不 会和产生它的规则脱节:

BatchBreak 含义
RunStart 没有可并入的对象——一段的第一个调用。
Instanced 实例化绘制:每个发射器一条命令,从不合并。
Shader 着色器切换——没有任何东西能跨过去合批。
Blend 混合模式切换——加法/普通内容交错。
Layout 顶点布局切换(精灵四边形 vs 网格顶点)。
Material 材质切换——按材质给实体分组。
Depth 深度测试/写入不同——不透明与混合内容交错。
Cull 剔除状态不同。
State 其他某个渲染状态标志不同。
Scissor 裁剪矩形变化(UIMask 边界)。
Stencil 模板参考值变化(遮罩写入/测试边界)。
IndexGap 索引不连续——排序把别的东西插在了两者之间。
TextureSlots 本可合并,但合并后的贴图集合超出了 8 个纹理槽——把更多贴图打进图集。

None 是合批自己的答案,永远不会出现在捕获到的调用上:走到它的命令被折进了前一 个调用,而不是新开一个。

同一套原因还会按帧发布成 batch.break.* 计数器,与 batch.drawsbatch.merged 并列——所以“6 个 draw call”不用捕获也能读成“1 次起始 + 5 次索引断裂,另有 41 条命 令被合掉”。

捕获可以重放到任意 draw call,看这一帧是怎么一步步画出来的——编辑器的帧检查 器就是这么做的。replayToDrawCall(i) 把 draw call 0…i 重渲染进一张快照; getSnapshotImageData() 在 GPU 回读落地后解析出像素(WebGL 上立即,WebGPU 上晚 一个 tick):

import { Renderer } from 'esengine';
Renderer.replayToDrawCall(5); // draw calls 0..5 only
const img = await Renderer.getSnapshotImageData(); // ImageData | null
if (img) ctx2d.putImageData(img, 0, 0); // e.g. into a debug canvas

GLDebug 开关 wasm 渲染器内部的 GL 错误检查——默认关闭,因为检查有开销:

import { GLDebug } from 'esengine';
GLDebug.enable(); // check GL errors at key points
const errors = GLDebug.check('after-spawn'); // explicit check; returns error count
GLDebug.diagnose(); // dump renderer diagnostics to the console
GLDebug.disable();

check(context) 立即执行一次错误检查并返回发现的 GL 错误数,日志输出会带上你的 context 字符串,便于二分定位错误出现在帧内的哪个位置。

已释放的贴图会驻留在一个按字节预算的温缓存里(完整模型见 资源指南)。这里是它的诊断面:

import { getResourceStats, setTextureBudget, trimTextureCache } from 'esengine';
setTextureBudget(256 * 1024 * 1024); // resize the budget (0 = no warm cache)
const stats = getResourceStats(); // ResourceStats | null before engine init
if (stats && stats.textureBytes > stats.textureBudget * 0.9) {
const freed = trimTextureCache(); // drop every evictable texture now
console.log(`freed ${freed} cached textures`);
}
ResourceStats 字段 说明
shaderCount 存活的已编译着色器数。
textureCount 存活的 GPU 贴图数。
vertexBufferCount / indexBufferCount 存活的 GPU 缓冲数。
cacheHits / cacheMisses 资源缓存命中/未命中计数。
textureBytes 驻留贴图字节数(RGBA8 估算)——持有的 + 可逐出的。
textureBudget 当前驻留字节预算(0 = 关闭逐出缓存)。
textureEvictableCount 引用数为 0、等待复活或逐出的缓存贴图数。

trimTextureCache() 立即释放所有可逐出条目并返回释放的贴图数——引擎在收到 OS 内存警告时会调用它;在已知的内存尖峰前你也可以主动调用。被持有(有引用)的贴图 和预算本身不受影响。

evictTextureDimensions(handle) 丢弃 SDK 侧缓存的某个贴图句柄的宽高,下一次 getTextureDimensions 查询会重新从引擎读取——只有在同一句柄下替换贴图内容的 工具代码才会用到。

每个 Appapp.subsystems 上携带一个 SubsystemRegistry,跟踪各引擎子系统的 生命周期阶段(registeredinitializingready,error 为终态)和派生的 活跃度(stepping / idle / inactive)——回答“物理到底在不在跑?”:

for (const s of app.subsystems.getStatuses()) {
console.log(`${s.displayName}: ${s.phase} (${s.activity})`, s.lastError ?? '');
}

recentEvents() 返回最近的生命周期转换,subscribe(fn) 在阶段变化时通知。完整的 生命周期模型见 App 与生命周期

桌面编辑器的剖析 UI 建立在同一套表面上:Profiler 面板显示实时帧时间曲线以及 逐阶段、逐系统的分解(点击某帧可检查它),视口里还有一个小的性能浮层用来一眼看 FPS。对游戏内构建,上面的 statsPlugin 就是对应物。

  • 发布时不带 statsPlugin(或用调试开关门控)——耗时采集默认关闭是有原因的; 剖析时再加。
  • 先归因再优化 — 读 systemTimings / phaseTimings 找到哪个系统慢再动代码; 浮层的 max 列能抓住平均值掩盖的单帧尖峰。
  • 用帧捕获修 draw callbreakReason 点名打断每个批次的那次状态变化 (TextureSlots → 打图集;Material → 按材质分组)。
  • 用类别记日志,别用 console.log — 结构化条目让 handler 能按通道和级别过滤, setLogLevel(LogLevel.Debug) 不改代码就能打开详细输出。
  • 生产环境关闭 GLDebug — 逐调用的 GL 错误检查很贵;只在追渲染 bug 时开。
  • 在内存受限的目标上盯住 textureBytestextureBudget 的关系,并在已知 尖峰前 trimTextureCache()
  • 资源 — 资源统计所观察的贴图温缓存与预算。
  • App 与生命周期 — 插件、调度与子系统注册表。
  • 系统 — 调度与系统名(systemTimings 的键)。
  • 编辑器 — 桌面编辑器,含其 Profiler 面板。