主题
移动端 Canvas 性能调优
前置知识:先阅读 canvas-2d-game 了解 Canvas 绘制、CanvasAnimated、设计分辨率和生命周期。
本 skill 面向游戏项目中的 Canvas / CanvasAnimated 使用代码。
先判断是否该降分辨率
优先考虑降低 Canvas framebuffer(底层绘制缓冲)分辨率的场景:
- 移动设备上
Canvas/CanvasAnimated每帧绘制内容很复杂; - UI 或游戏在手机上发热、掉帧,但桌面端正常;
- 全屏 Canvas、半屏小地图、大面积特效 HUD 等覆盖区域很大;
- 运行日志或表现显示渲染纹理、framebuffer 或底层绘制缓冲压力;
- 画面允许轻微变软,例如背景、轨迹、粒子、低频 HUD。
先不要降分辨率的场景:
- 主要内容是小字号文字、1px 线条、细 UI 图标;
- Canvas 只静态绘制一次,不是每帧更新;
- FPS 正常但动画抖动、忽快忽慢或跳帧感明显,先排查动画时间计算;
- 性能瓶颈来自逻辑计算、资源反复加载、频繁创建/销毁控件或未
Destroy(); - 普通 UI 能用
Label、Image、Sprites等控件表达,不需要 Canvas 每帧重画。
先确认是帧率问题还是时间计算问题
降低 framebuffer 前,先把轻量帧诊断直接画在 Canvas 上。很多“卡顿感”并不是手机 GPU 压力导致的真实掉帧,而是动画时间计算错误;这类问题不限于手机平台,降低 Resolution 也不会解决。
优先画 overlay,而不是只打日志。日志通常按固定窗口输出,容易错过抖动发生的画面时刻;overlay 会跟着当前画面一起更新,更容易看出“这一帧到底是 delta 飙了、绘制耗时飙了,还是动画自己算错了”。日志可以作为后续留档,不要作为第一诊断手段。
按项目写法选择诊断位置:
- 用
CanvasAnimated.OnAnimatedRender驱动动画:显示e.DeltaTimeInSeconds,再包一层绘制耗时; - 用
Think(float dt)更新逻辑、Canvas 只负责绘制:在Think(dt)里统计逻辑 delta,在 Render 里统计绘制耗时; - 用普通
Canvas.OnRender:自行统计两次 Render 之间的时间间隔,并统计单次绘制耗时。
下面的示例每帧在 Canvas 左上角绘制当前 FPS、当前 delta、当前绘制耗时,以及 1 秒窗口的最大值。字体路径替换成项目中已有的 Canvas 字体文件:
csharp
#if CLIENT
using System.Diagnostics;
using System.Drawing;
using GameUI.Control.Primitive;
private static readonly Stopwatch _frameClock = Stopwatch.StartNew();
private static readonly Stopwatch _drawClock = new();
private static int _perfOverlayFontId = -1;
private static double _lastRenderSeconds;
private static double _sampleSeconds;
private static int _sampleFrames;
private static float _currentFps;
private static float _currentDeltaMs;
private static float _currentDrawMs;
private static float _windowMaxDeltaMs;
private static float _windowDrawMsTotal;
private static float _windowMaxDrawMs;
private static float _overlayFps;
private static float _overlayAvgDrawMs;
private static float _overlayMaxDeltaMs;
private static float _overlayMaxDrawMs;
private static void BeginFrameSample(float? knownDeltaSeconds = null)
{
double now = _frameClock.Elapsed.TotalSeconds;
if (_lastRenderSeconds <= 0)
{
_lastRenderSeconds = now;
}
float deltaSeconds = knownDeltaSeconds ?? (float)Math.Max(0.0, now - _lastRenderSeconds);
_lastRenderSeconds = now;
_currentDeltaMs = deltaSeconds * 1000f;
_currentFps = 1f / Math.Max(0.0001f, deltaSeconds);
_sampleSeconds += deltaSeconds;
_windowMaxDeltaMs = MathF.Max(_windowMaxDeltaMs, _currentDeltaMs);
_drawClock.Restart();
}
private static void EndFrameSample()
{
_drawClock.Stop();
float drawMs = (float)_drawClock.Elapsed.TotalMilliseconds;
_currentDrawMs = drawMs;
_windowDrawMsTotal += drawMs;
_windowMaxDrawMs = MathF.Max(_windowMaxDrawMs, drawMs);
_sampleFrames++;
if (_sampleSeconds < 1.0)
{
return;
}
_overlayFps = _sampleFrames / (float)Math.Max(0.001, _sampleSeconds);
_overlayAvgDrawMs = _windowDrawMsTotal / Math.Max(1, _sampleFrames);
_overlayMaxDeltaMs = _windowMaxDeltaMs;
_overlayMaxDrawMs = _windowMaxDrawMs;
_sampleSeconds = 0;
_sampleFrames = 0;
_windowMaxDeltaMs = 0;
_windowDrawMsTotal = 0;
_windowMaxDrawMs = 0;
}
private static void EnsurePerfOverlayFont()
{
if (_perfOverlayFontId >= 0)
{
return;
}
_perfOverlayFontId = Canvas.CreateFont(
"CanvasPerfOverlay",
"ui/font/regular/RegularBold.otf");
}
private static void DrawFrameStatsOverlay(Canvas canvas)
{
EnsurePerfOverlayFont();
canvas.SaveState();
canvas.ResetTransform();
canvas.FillPaint = Color.FromArgb(180, 0, 0, 0);
canvas.FillRoundedRectangle(12, 12, 390, 112, 8);
canvas.FontFaceId(_perfOverlayFontId);
canvas.FontSize = 22f;
canvas.FillPaint = _currentDeltaMs > 22f ? Color.OrangeRed : Color.White;
canvas.DrawText(24, 40, string.Format("FPS {0:0} dt {1:0.0} ms", _currentFps, _currentDeltaMs));
canvas.FillPaint = _currentDrawMs > 8f ? Color.Orange : Color.White;
canvas.DrawText(24, 70, string.Format("draw {0:0.0} ms avg {1:0.0} ms", _currentDrawMs, _overlayAvgDrawMs));
canvas.FillPaint = Color.White;
canvas.DrawText(24, 100, string.Format("1s max dt {0:0.0} ms max draw {1:0.0} ms", _overlayMaxDeltaMs, _overlayMaxDrawMs));
canvas.RestoreState();
}
#endif在 CanvasAnimated.OnAnimatedRender 中:
csharp
private static void DrawFrame(object? sender, CanvasAnimatedEventArgs e)
{
BeginFrameSample(e.DeltaTimeInSeconds);
// 原有绘制代码...
EndFrameSample();
DrawFrameStatsOverlay(_canvas);
}如果逻辑写在 Think(float dt) 里,可以在 Think(dt) 中另行统计 dt 的当前值和最大值,并把结果也画到同一个 overlay 上,和 Render 统计对照。诊断完成后移除 overlay,或只在调试开关打开时绘制。
判断口径:
| 现象 | 优先判断 | 处理方向 |
|---|---|---|
| FPS 低、maxDelta 高、drawMs 也高 | 真实帧耗时或绘制耗时问题 | 再考虑降低 Resolution、减少绘制、缓存或拆层 |
| FPS 正常、drawMs 不高,但动画忽快忽慢 | 动画时间计算问题 | 检查是否用帧数驱动动画、重复累加 delta、混用固定步长和真实 delta |
Think(dt) 平稳,Render 统计不平稳 | 绘制或提交压力更可疑 | 重点看 Canvas 绘制量、图片/字体创建、framebuffer 尺寸 |
| 仅移动端统计异常,桌面正常 | 移动端资源压力更可疑 | 结合 DeviceInfo.Platform 启用移动端质量预算 |
核心概念
Canvas.Resolution 是 Canvas 的 framebuffer(底层绘制缓冲)分辨率。控件本身的屏幕显示尺寸由 GameUI 布局决定。
默认情况下:
AutoUpdateResolutionOnResize = true;Resolution会随控件实际尺寸自动更新;- 全屏 Canvas 通常会获得接近屏幕像素密度的底层绘制质量;
- 质量高,但移动端大面积每帧绘制时成本也高。
手动调优时,把这两件事分开:
- 控件尺寸:继续
FullScreen()或按布局铺满目标区域; - 绘制分辨率:手动把
Resolution设为较小尺寸,让系统把结果放大显示到控件区域。
这会降低像素填充、显存和 framebuffer 压力,代价是画面变软。
推荐做法:按像素预算选择质量
不要按“手机分辨率越高 GPU 越强”来判断。屏幕物理分辨率和 GPU 持续性能只有弱相关,还会受到高刷、温控和内存带宽影响。
先按平台收窄范围,避免误伤桌面体验。优先使用 WasiCore / GameUI 公开的 GameUI.Device.DeviceInfo.Platform 和 EngineInterface.Enum.Platform 判断平台;GameSystemUI.Utility.IsMobile() 是 GameSystemUI 的包装方法,只有项目已经依赖 GameSystemUI 这个额外官方库时再考虑使用。
更稳的策略是:桌面端保持原始质量,移动端或编辑器移动模拟环境限制单个 Canvas 的底层像素预算。
csharp
#if CLIENT
using System.Drawing;
using EngineInterface.Enum;
using GameUI.Control.Enum;
using GameUI.Control.Primitive;
using GameUI.Control.Primitive.Struct;
using GameUI.Device;
private static SizeF _canvasLogicalSize;
private static float _canvasQuality = 1.0f;
private static bool _canvasResolutionReady;
private static bool ShouldReduceCanvasFramebuffer()
{
var platform = DeviceInfo.Platform;
return platform == Platform.Android
|| platform == Platform.iOS
|| platform == Platform.EditorMobileSimulation;
}
private static float ChooseCanvasQuality(SizeChangedEventArgs e, float maxPixels)
{
if (!ShouldReduceCanvasFramebuffer())
{
return 1.0f;
}
float fullPixels = MathF.Max(1f, e.WidthPx * e.HeightPx);
return Math.Clamp(MathF.Sqrt(maxPixels / fullPixels), 0.5f, 1.0f);
}
private static void ApplyCanvasQuality(Canvas canvas, SizeChangedEventArgs e)
{
_canvasLogicalSize = e.Size;
_canvasQuality = ChooseCanvasQuality(e, maxPixels: 1_500_000f);
canvas.Resolution = new SizeF(
_canvasLogicalSize.Width * _canvasQuality,
_canvasLogicalSize.Height * _canvasQuality);
_canvasResolutionReady = true;
}
#endifmaxPixels 可按内容调整:
| 内容类型 | 建议预算 |
|---|---|
| 全屏动态背景、粒子、轨迹 | 1.0M - 1.5M |
| 玩法主画面,需要较清晰 | 1.5M - 2.5M |
| 文字/细线较多 | 优先 2.0M 以上,或不要降采样 |
套用到 Canvas / CanvasAnimated
关键点:
- 关闭自动分辨率更新;
- 先用
DeviceInfo.Platform判断是否属于移动端,桌面端默认保留quality = 1.0f; - 保持
CoordinateMode = CanvasCoordinateMode.DesignResolution; - 在
OnSizeChanged中更新Resolution; - 绘制时先
Scale(quality, quality),让原本按完整逻辑尺寸写的绘制代码缩进低分辨率画布。
csharp
#if CLIENT
private static CanvasAnimated _canvas = null!;
private static void CreateCanvas()
{
_canvas = new CanvasAnimated();
_canvas.FullScreen();
_canvas.CoordinateMode = CanvasCoordinateMode.DesignResolution;
_canvas.AutoUpdateResolutionOnResize = false;
_canvas.OnSizeChanged += (_, e) => ApplyCanvasQuality(_canvas, e);
_canvas.OnAnimatedRender += DrawFrame;
_canvas.StartTiming();
_canvas.AddToVisualTree();
}
private static void DrawFrame(object? sender, CanvasAnimatedEventArgs e)
{
if (!_canvasResolutionReady)
{
return;
}
BeginFrameSample(e.DeltaTimeInSeconds);
_canvas.ResetState();
_canvas.SaveState();
_canvas.Scale(_canvasQuality, _canvasQuality);
// DrawGame 仍按完整逻辑尺寸写,不需要到处乘 quality。
DrawGame(_canvas, _canvasLogicalSize.Width, _canvasLogicalSize.Height, e.DeltaTimeInSeconds);
_canvas.RestoreState();
EndFrameSample();
DrawFrameStatsOverlay(_canvas);
}
private static void DestroyCanvas()
{
_canvas.OnAnimatedRender -= DrawFrame;
_canvas.Destroy();
}
#endif如果是普通 Canvas.OnRender,同样套用 ResetState()、SaveState()、Scale(...)、RestoreState()。
动态质量调节
如果项目已经能采集帧耗时,可以动态调整 maxPixels 或 _canvasQuality。保持保守:
- 不要每帧改变
Resolution,否则会反复重建 framebuffer 相关资源; - 每 1-3 秒最多调整一次;
- 使用滞后区间,例如连续多帧高于目标耗时才降低,稳定一段时间后才升高;
- 降低质量时可以快一些,恢复质量时慢一些;
- 最低质量通常不要低于
0.5,除非该 Canvas 只是模糊背景或装饰。
示意:
csharp
// 伪代码:把真实 frameMs 来源替换成项目自己的性能采样。
if (frameMs > 22f && stableForSeconds > 2f)
{
currentMaxPixels *= 0.8f;
ReapplyCanvasQualityOnNextResizeOrTimer();
}
else if (frameMs < 14f && stableForSeconds > 8f)
{
currentMaxPixels = MathF.Min(currentMaxPixels * 1.1f, 2_500_000f);
ReapplyCanvasQualityOnNextResizeOrTimer();
}其他优先优化项
降低 Resolution 不是唯一手段。排查时同时检查:
- 静态内容不要每帧
ResetState()+ 全量重画;能缓存、分层或只画一次就不要动态重画。 - 不要在
OnRender/OnAnimatedRender中反复new Image(...)、Canvas.CreateFont(...)或加载资源。 - 复杂 HUD 尽量拆到普通 GameUI 控件;文字用
Label,简单序列帧用Sprites。 - 不再复用的 Canvas/UI 调用
Destroy(),不要只RemoveFromParent()或由父容器ClearChildren()摘掉。 - 多个大 Canvas 叠加时,优先减少数量,而不是每个都全屏高质量。
验收清单
- [ ] 调优前先在 Canvas 上绘制帧诊断 overlay,确认是真实帧率/绘制耗时问题,不是动画时间计算错误;
- [ ] 真机上对比调优前后的帧耗时、发热和视觉质量;
- [ ] 桌面端或 PC 调试场景没有被移动端降采样策略误伤;
- [ ] Canvas 仍然铺满原本的 UI 区域;
- [ ] 旋转屏幕或改变窗口尺寸后
Resolution会重新设置; - [ ] 文字、细线、图标、命中反馈没有糊到不可接受;
- [ ] 没有在每帧反复改变
Resolution; - [ ] 临时 Canvas 关闭时调用
Destroy()。
常见错误
| 症状 | 原因 | 解决 |
|---|---|---|
手动设置 Resolution 后又恢复高成本 | AutoUpdateResolutionOnResize 仍为 true | 手动调优前设为 false |
只调用 Scale(0.5, 0.5) 但资源占用没降 | Scale 只变换绘制坐标,不改变底层绘制分辨率 | 同时降低 Resolution |
| 内容只画了左上角一小块 | 降低 Resolution 后没有同步 Scale(quality, quality) | 绘制前缩放坐标系 |
| 触摸位置/游戏逻辑被 quality 影响 | 把业务坐标也乘了 quality | 业务逻辑继续用完整逻辑尺寸,只在绘制阶段缩放 |
| 画面文字发糊 | 对文字型 Canvas 降采样过多 | 提高预算,或改用普通 Label |
| 桌面端画面也变糊 | 没有先做平台判定,所有平台都套用了移动端质量预算 | 用 DeviceInfo.Platform 限定 Android / iOS / 编辑器移动模拟等目标平台 |
降低 Resolution 后动画仍然抖 | 问题来自时间计算而非绘制压力 | 先对照 Render delta 和 Think(dt),修正动画时间来源 |