Skip to content

移动端 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;
}
#endif

maxPixels 可按内容调整:

内容类型建议预算
全屏动态背景、粒子、轨迹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),修正动画时间来源

相关文档 ​