游戏性能分析相关

性能分析相关

本文内容大多来自于Unity官方电子书关于移动端性能部分,推荐一看。

性能分析流程参考:

博客园图片

CPU-bound

如果 CPU 是瓶颈,需查看哪种线程是最繁忙

Main Thread(主线程)

游戏的大部分逻辑与脚本默认在此执行。Unity 的多数系统(如物理、动画、UI 及渲染初始阶段)均运行于此线程。

想要实现最大幅度的性能提升,核心是优化耗时最长的模块。对于主线程性能受限的项目,以下是优化收益极高的核心方向:

  • 物理计算
  • MonoBehaviour脚本更新
  • 托管内存分配与垃圾回收(GC)
  • 主线程执行的相机剔除与渲染
  • 低效的绘制调用合批(Draw Call Batching)
  • UI 更新、布局计算与重建
  • 动画系统

物理系统导致性能问题

Unity中的 Fixed Timestep 影响着物理系统的执行间隔,该值默认是 0.02s(20ms)。

博客园图片

但我们知道游戏运行中,一帧的执行时间并非固定的,有时可能会耗时10ms,有时可能会耗时40ms,以这两个为例来看看想到物理系统执行间隔,耗时短和耗时长分别会带来什么影响:

  • 当单帧耗时短,物理系统就不会每帧都运行。而是等到时间达到设定值时才运行,例如有连续两帧耗时都是10ms,那么物理系统就会在第一帧时不运行,第二帧累计达到20ms时再运行。
  • 当单帧耗时长,就可能带来性能问题了。假设单帧运行耗时达到 40ms,下一帧物理系统就会连续执行两次,而一帧内执行多次物理系统也会加重这帧的耗时,导致下一帧物理系统执行更多次……这是个恶性循环,后面每帧的耗时可能会越来越长,直到超过 Maximum Allowed Timestep,Unity才舍弃部分物理更新。但在这帧中,游戏必然发生卡顿,并且会出现部分物理模拟失效的情况,可谓是雪上加霜了。

Render Thread(渲染线程)

负责完成向 GPU 发送渲染指令前的准备工作(例如判断场景中哪些对象对相机可见、哪些因位于视锥体之外、被遮挡或按其他条件剔除而不可见)。

渲染过程中,主线程遍历场景并执行相机剔除、深度排序与绘制调用批处理,生成待渲染列表。该列表被传递至渲染线程,由其将 Unity 内部跨平台表示转换为特定平台所需的图形 API 调用(如 DirectX、Vulkan 或 Metal),以指令 GPU 完成渲染。

以下是渲染线程受限项目中需要排查的常见原因:

  • 绘制调用批处理不佳:这种情况在旧版图形 API 中尤为明显,例如 OpenGL 或 DirectX 11。
  • 相机数量过多:除非开发分屏多人游戏(像《双人成行》这类的),否则通常只应保留一个活跃相机。
  • 剔除效果不佳:这会导致过多物体被绘制。请检查相机的视锥体范围与剔除层掩码(LayerMask)。

渲染分析器模块会显示每帧的绘制调用批处理数量与通道设置调用次数。排查渲染线程向 GPU 发送哪些绘制调用批处理的最佳工具是 Frame Debugger

image

Job Worker Thread(作业工作线程)

开发者可通过 job system 将部分任务调度至工作线程执行,从而减轻主线程负载。Unity 的部分系统与功能(如物理、动画、渲染)也会使用 job system

Worker Thread 瓶颈的常见原因包括:

  • 未通过 Burst 编译器编译
  • 单个工作线程上运行长时间任务,而非在多个工作线程间并行执行
  • 帧中任务调度节点与结果需求节点之间时间不足
  • 一帧内存在多个同步点,要求所有任务立即完成

你可以在CPU性能分析器模块的时间轴视图中使用流事件功能,来查看任务的调度时机以及主线程对其结果的预期时间。

博客园图片

PS:个人在移动端游戏的性能测试相关工作中尚未遇到由其引发的性能问题,用得不多。

GPU-bound

如果主线程在 Gfx.WaitForPresentOnGfxThread 等性能分析标记中消耗大量时间,且渲染线程同时显示 Gfx.PresentFrame<GraphicsAPIName>.WaitForLastPresent 等标记,则说明应用程序处于 GPU 受限状态。

分析 GPU 性能时需排查的常见问题包括:

  • 开销高昂的全屏后处理效果,如环境光遮蔽(Ambient Occlusion)和泛光效果(Bloom)
  • 因以下原因导致开销过大的片元着色器:
    • 着色器代码内存在分支逻辑
    • 使用全浮点精度而非半精度,尤其在移动端
    • 寄存器使用过多,影响 GPU 波前占用率
  • 透明渲染队列中的过度绘制,原因包括:
    • 低效的 UI 渲染
    • 粒子系统重叠或使用过量
    • 后处理效果
  • 屏幕分辨率过高,例如:
    • 4K 显示器
    • 移动设备上的视网膜显示屏
  • 微型三角形由以下原因导致:
    • 密集的网格几何体
    • 缺乏细节层次(LOD)系统,这在移动 GPU 上尤为突出,但也会影响 PC 和主机 GPU
  • 缓存未命中及 GPU 内存带宽浪费由以下原因导致:
    • 未压缩纹理
    • 无多级纹理(mipmap)的高分辨率纹理
  • 几何体或曲面细分着色器若启用动态阴影,每帧可能多次执行

了解Shader

Unity Shader 的书写遵循「从全局到局部、从配置到逻辑」的固定顺序,以下按实际编码时的书写顺序,逐个拆解每个核心组成部分:

1. 顶层:Properties(材质可配置属性)

在 Shader 最顶端(所有 SubShader 之前)定义,是着色器对外暴露的可配置参数接口,这些属性会显示在 Unity 材质面板中,支持编辑器/脚本调整。

  • 声明语法:内部变量名 ("面板显示名", 属性类型) = 默认值
    (注:该语法为变量指定两个名称—— 内部名 供 shader 代码使用,显示名 供材质球面板查看使用)
  • 使用规则:需在后续着色器代码中声明同名变量,名称必须完全匹配(大小写、下划线均不可错)。
  • 扩展特性:变量前可加 [XXX] 类型标记(属性特性),控制面板显示样式/行为。

示例:

Properties {
    // 基础声明:内部名_Tint,面板显示名Tint,颜色类型,默认白色
    _Tint ("Tint", Color) = (1, 1, 1, 1)

    // 带属性特性的声明
    [MainColor] _MainColor ("主颜色", Color) = (1,1,1,1) // 标记为主颜色
    [Range(0,1)] _Opacity ("透明度", Float) = 1 // 滑块控制数值范围
    [Toggle] _UseAlpha ("启用透明", Float) = 0 // 开关控件
    [HideInInspector] _HiddenVal ("隐藏参数", Float) = 0 // 面板隐藏
}

2. 核心层:SubShader(子着色器)

Shader 主体由 1~N 个 SubShader 组成,Unity 会从上到下选择第一个满足当前平台/硬件条件的 SubShader 执行,因此书写时通常将 高质量实现写在前,低质量写在后
SubShader 内部可配置「全局规则」(作用于所有 Pass),也可嵌套 Pass 定义具体渲染逻辑,核心配置项如下:

2.1 SubShader 根层级:LOD(画质分级)

写在 SubShader 最外层,是唯一可通过 QualitySettings 外部控制的适配条件,决定当前画质档位是否启用该 SubShader。
示例:

SubShader {
    LOD 300 // 全局LOD<300时,该SubShader被跳过(顶配版)
    // 其他配置...
}
SubShader {
    LOD 100 // 全局LOD≥100时可用(低配版)
    // 其他配置...
}
2.2 SubShader 根层级:Tags(渲染调度标签)

告诉 Unity 引擎「何时渲染、如何调度」该 SubShader,不直接控制 GPU 行为,仅作用于引擎调度逻辑(如渲染管线适配、渲染队列)。
示例:

SubShader {
    Tags {
        "RenderPipeline"="UniversalPipeline" // 仅适配URP管线
        "Queue"="Transparent" // 透明队列渲染
        "IgnoreProjector"="True" // 忽略投影器
    }
    // 其他配置...
}
2.3 SubShader 根层级:渲染状态(全局渲染规则)

控制 GPU 核心渲染逻辑(剔除、深度、混合、模板测试等),作用于该 SubShader 下所有 Pass;若 Pass 内声明同名状态,会覆盖 SubShader 层级的配置。
核心渲染状态包含:

  • 基础渲染状态:Cull(剔除)、ZWrite(深度写入)、ZTest(深度测试)、Blend(混合)等(单行简单指令);
  • Stencil(模板测试):像素级过滤/标记工具,实现局部遮挡、UI 遮罩等效果(多参数配置块)。

Stencil 示例(SubShader 层级):

SubShader {
    // 全局Stencil规则(作用于所有Pass)
    Stencil {
        Ref 1                // 参考值(0~255,8位无符号整数)
        Comp Equal           // 比较规则:仅模板缓冲值=Ref时通过
        Pass Replace         // 测试通过时,用Ref替换模板缓冲值
        ReadMask 255         // 读掩码:全位参与比较
        WriteMask 255        // 写掩码:全位允许写入
    }
    // 其他配置...
}

3. 执行层:Pass(渲染通道)

SubShader 中至少包含 1 个 Pass,是 Shader 定义实际渲染行为的最小单元——每个执行的 Pass 都会先触发一次 SetPass Call。
Pass 内可覆盖 SubShader 层级的渲染状态(如单独设置 Cull Off),核心逻辑为 GPU 着色器代码块。

3.1 Pass 内:CG/HLSL 代码块

通过 CGPROGRAM / ENDCG(内置管线)或 HLSLPROGRAM / ENDHLSL(SRP 管线)包裹,定义顶点/片元着色器逻辑。

  • 代码组织:用 #include 加载外部文件(如内置库、自定义工具函数);
  • 硬件约束:用 #pragma target XX/#pragma require XX 声明硬件能力要求,不满足则整个 SubShader 被跳过。

示例(硬件约束):

Pass {
    HLSLPROGRAM
    #pragma target 3.0 // 要求SM3.0显卡
    #pragma require instancing // 要求GPU实例化支持
    #include "UnityCG.cginc" // 加载内置工具库
    // 其他着色器代码...
    ENDHLSL
}
3.2 Pass 内:顶点着色器(Vertex Shader)

负责处理顶点数据,完成「对象空间 → 裁剪空间」的坐标变换,同时向下传递纹理坐标、法线等数据。

  • 入口指定:#pragma vertex 函数名
  • 核心要求:返回的结构体中必须包含 SV_POSITION 语义标记的 float4 变量(裁剪空间位置)。
3.3 Pass 内:片元着色器(Fragment Shader)

负责光栅化后每个像素的颜色计算,是最终渲染效果的核心。

  • 入口指定:#pragma fragment 函数名
  • 输入:接收顶点着色器输出、经光栅化插值后的数据(如纹理坐标、颜色);
  • 输出:返回 SV_Target 语义标记的 float4 颜色值(渲染目标最终像素颜色)。

顶点/片元着色器示例:

Pass {
    CGPROGRAM
    #pragma vertex vert // 指定顶点着色器入口
    #pragma fragment frag // 指定片元着色器入口

    // 声明Properties中定义的变量(名称完全匹配)
    float4 _Tint;

    // 顶点着色器输入结构体
    struct appdata {
        float4 vertex : POSITION; // 模型空间顶点位置
        float2 uv : TEXCOORD0; // 纹理坐标
    };

    // 顶点着色器输出(片元着色器输入)结构体
    struct v2f {
        float2 uv : TEXCOORD0;
        float4 pos : SV_POSITION; // 裁剪空间位置(必须)
    };

    // 顶点着色器入口函数
    v2f vert (appdata v) {
        v2f o;
        o.pos = UnityObjectToClipPos(v.vertex); // 坐标变换
        o.uv = v.uv;
        return o;
    }

    // 片元着色器入口函数
    float4 frag (v2f i) : SV_Target {
        return _Tint; // 输出Tint指定的颜色
    }
    ENDCG
}

调整移动端帧预算

应对设备长时间运行产生的发热问题,通用技巧是 预留约 35% 的帧空闲时间。这能让移动芯片获得降温时间,并有助于防止电池过度耗电。

以每帧 33.33 毫秒的目标帧时间(对应 30 帧 / 秒)计算,移动设备的帧预算约为每帧 22 毫秒。

计算公式如下:$(1000 ms / 30) × 0.65 = 21.66 ms $

移动设备芯片的频率调节机制,会让你在性能分析时难以准确确定帧空闲时间预算的分配情况。你的优化改进措施可能整体效果良好,但移动设备可能会自动降低频率,从而使运行温度更低。

建议使用 FTrace 或 Perfetto 等定制工具,在优化前后监测移动芯片频率、空闲时间及频率调节状态。(实际体验了一下工具,对设备硬件有要求,一些中低端机无法监测)

部分游戏类型在高端设备中也有高帧率的需求,例如射击游戏,随着手机性能的提升,60 帧 / 秒、120 帧 / 秒甚至更高的帧率成为主流,对帧预算的控制提出了更严苛的要求。

内存分析

减少内存访问

在移动设备上,动态随机存取存储器(DRAM)访问通常是一项高功耗操作。Arm 针对移动设备图形内容的优化建议指出,低功耗第四代双倍数据率(LPDDR4)内存访问每字节约消耗 100 皮焦耳。

通过以下方式减少每帧的内存访问次数:

  • 降低帧率
  • 在可行情况下降低显示分辨率
  • 使用顶点数更少、属性精度更低的简化网格
  • 采用纹理压缩与多级纹理映射(MipMapping)

影响内存占用的关键设置

  • 画质和图形设置会影响阴影贴图所用渲染纹理的大小
  • 分辨率缩放会影响屏幕缓冲区、渲染纹理和后处理效果的大小
  • 纹理设置会影响所有纹理的大小
  • 最大 LOD(细节层次)会影响模型及其他资源的内存占用
  • 如果你的资源包(AssetBundle)存在不同版本,例如高清版(HD)与标清版(SD),并且你会根据目标设备的配置选择使用对应版本,那么在不同设备上进行分析时,可能会得到不同的资源体积数据。
  • 目标设备的屏幕分辨率会影响用于后期处理效果的渲染纹理大小。
  • 设备所支持的图形应用程序编程接口(API),可能会因其对着色器不同变体的支持情况,影响着色器的内存占用大小。
  • 采用分级系统,搭配不同的画质与图形设置以及资源包变体,是适配更多类型设备的绝佳方式。
    • 例如,你可以在 4GB 运行内存的移动设备上加载高清版资源包,在 2GB 运行内存的设备上加载标清版资源包。不过,需牢记上述内存占用的各项差异,并确保对这两类设备,以及具备不同屏幕分辨率或支持不同图形 API 的设备均进行测试。

降低垃圾回收(GC)带来的影响

Unity 使用 Boehm-Demers-Weiser 垃圾回收器,该回收器会暂停程序代码的运行,直至工作完成后才恢复正常执行。

需注意某些不必要的堆内存分配,它们会引发 GC 峰值:

  • 字符串:
    C# 中,字符串属于引用类型而非值类型。若大规模使用字符串,应减少不必要的创建与操作。避免解析基于字符串的数据文件(如 JSON、XML),改用脚本化对象(Scriptable Object)或 MessagePack、Protobuf 等格式存储数据。如需在运行时拼接字符串,使用 StringBuilder 类。

  • Unity 函数调用:
    部分函数会产生堆内存分配。缓存数组引用,而非在循环中重复分配数组。同时优先使用不会产生垃圾的函数,例如用 GameObject.CompareTag 替代手动比较 GameObject.tag(后者会返回新字符串并产生垃圾)。

  • 装箱操作:
    避免将值类型变量当作引用类型变量传入。这会创建临时对象并产生垃圾,因为系统会隐式将值类型转换为 object 类型(例如 int i = 123; object o = i)。应针对目标值类型提供具体重载方法,也可使用泛型实现。


    C# 里数据分两类:

    类型 例子 存储位置 特点
    值类型 int、float、struct 栈(Stack) 轻量、创建/销毁快、用于函数内的局部值类型变量
    引用类型 string、object、class 堆(Heap) 需GC回收、有内存开销

    可以用一个水瓶结构粗略理解它们,栈就像瓶盖的容积一样小,堆就像瓶身一样大:

    image

  • 协程:
    虽然 yield 本身不产生垃圾,但新建 WaitForSeconds 对象会产生垃圾。缓存并复用 WaitForSeconds 对象,而非在 yield 语句中直接创建。

    public class UseWaitCache : MonoBehaviour
    {
        private WaitForSeconds timeWait = new WaitForSeconds(1f);
    
        void Start()
        {
            StartCoroutine(MyCoroutine());
        }
    
        IEnumerator MyCoroutine()
        {
            // 如果用 yield return new WaitForSeconds(1f) 就会有CG
            yield return timeWait;
            Debug.Log("1秒后执行");
        }
    }
    
  • LINQ 与正则表达式:
    二者均会在后台通过装箱操作产生垃圾。若性能敏感,应避免使用 LINQ 与正则表达式。改用 for 循环、列表等方式替代新建数组。

posted @ 2026-05-28 17:33  狐王驾虎  阅读(11)  评论(0)    收藏  举报