Done is better than perfect

0%

性能优化-CPU

闪存优化

闪存用于存储游戏数据和资源,包括游戏数据、纹理、音频等。闪存的读写速度比内存慢得多,因此在游戏中,我们需要尽可能地减少闪存的读写次数,以提高游戏的性能。

闪存结构和文件操作流程

性能优化-基础 中,介绍了相关的基础知识,为了文章的完整性,简单回顾一下。

  1. 闪存结构
  • SOC系统中的闪存一般采用NAND Flash或NOR Flash,作为非易失性存储器,用于存储操作系统、应用程序、资源文件等。
  • 闪存通过总线(如SPI、eMMC、UFS等)与SOC主控芯片连接。
  1. 文件读写流程
  • 文件读取:

    1. CPU发起文件读取请求,操作系统通过文件系统(如FAT、EXT4等)定位文件在闪存中的物理地址。
    2. 文件系统驱动将读取命令通过总线发送到闪存控制器。
    3. 闪存控制器根据地址从闪存芯片中读取数据,经过总线传输到SOC的内存(RAM)中。
    4. CPU从内存中获取数据进行处理。
  • 文件写入:

    1. CPU将需要写入的数据放入内存缓冲区。
    2. 操作系统通过文件系统分配闪存空间,并生成写入命令。
    3. 写入命令和数据通过总线传递给闪存控制器。
    4. 闪存控制器将数据写入指定的闪存地址。
    5. 写入完成后,文件系统更新元数据,保证数据一致性。
  • 应用层API:

    1. fopen, fread, fwrite, fclose等函数封装了文件操作的底层细节,但最终都是调用到操作系统API。
    2. 也可以使用内存映射的方式,将文件映射到内存中,直接操作内存,避免了文件直接调用API读取的过程, 映射后可以直接操作指针的方式读取和写入内存。

内存映射(Memory Mapping)机制及其优势

内存映射是指将磁盘文件直接映射到虚拟内存空间,从而支持按需访问,避免整体加载:

  • 传统 I/O 模式:调用 Read() 一次性将整个文件加载至内存;内存占用高。
  • 映射模式:调用 OS 提供的 mmap()MapViewOfFile(),按页访问数据;只有实际访问的数据才加载。

优点包括:

  • 降低内存消耗,提升启动速度
  • 支持文件级懒加载和异步加载
  • 适合大型资源(如场景/AssetBundle)流式访问

Unity 的 AssetBundle 加载系统内部即使用此策略,在移动平台和主机平台中应用广泛。

序列化(Serialization)

序列化的概念

Unity 的序列化(Serialization)体系分为编辑器写盘阶段和运行时读盘阶段,它同时支持 二进制格式(Binary SerializedFile)和 文本(YAML)格式。编辑器在构建场景、AssetBundle 或玩家(Player)时,将所有 UnityEngine.Object 派生的对象及其字段按照 “Type Tree + 对象数据” 的方式写入磁盘;运行时则根据磁盘上的 Type Tree 快速定位并重构内存中的 C++ 对象,并通过隐藏指针 m_CachedPtr 将之挂载到对应的 C# 托管对象上。二进制格式在读写时采用专门的 C++ 引擎代码和内存拷贝技术,支持内存映射 (.resS/.resource) 与多线程解压,是极高效的;而文本(YAML)格式则使用文本解析器和反射,仅在编辑器中针对小规模场景或开启 “Force Text” 时使用

序列化数据结构

Unity 采用自定义的 TypeTree 类型树机制进行资源的序列化与反序列化。其核心结构如下:

1. SerializedFileHeader

用于描述 .assets 文件的头部信息。

1
2
3
4
5
6
7
8
class SerializedFileHeader {
uint metadataSize;
uint fileSize;
uint version;
uint dataOffset;
byte endianness;
byte[] reserved;
}
  • metadataSize:元数据(TypeTree、ObjectInfo等)大小。
  • fileSize:整个资源文件的总大小(包括元数据和二进制数据)。
  • version:Unity版本序列化的版本,不是引擎版本(如 0x0D 表示某个格式版本)。
  • dataOffset:实际资源(如贴图、音频等二进制数据)在文件中的偏移位置。
  • endianness:字节序标记(0 = 小端,1 = 大端)。
  • reserved:保留字段(通常为16字节,填0)。

2. SerializedType

描述一个类型信息,包括该类的ID、TypeTree结构等。

1
2
3
4
5
6
7
8
class SerializedType {
int classID;
bool isStrippedType;
short scriptTypeIndex;
TypeTree typeTree;
string scriptIDHash;
string typeHash;
}
  • classID:Unity内部定义的类ID(如 1=GameObject, 28=Texture2D)。
  • isStrippedType:是否为裁剪类型(strip=True 表示此类型已裁剪,仅保留元信息)。
  • scriptTypeIndex:脚本索引(用于 MonoBehavior / ScriptableObject 指向具体脚本)。
  • typeTree:该类型的字段结构树。
  • scriptIDHash:用于 MonoScript 类型的 GUID 哈希值。
  • typeHash:用于校验类型树是否匹配。

3. TypeTree

表示一个复杂类型的字段结构信息。

1
2
3
4
class TypeTree {
List<TypeTreeNode> nodes;
string className;
}
  • nodes:字段树结构的节点数组(深度优先遍历展开)。
  • className:类型的名称(如 MonoBehaviour, GameObject)。

4. TypeTreeNode

字段节点的定义,描述一个字段的名称、类型及其在内存中的表现。

1
2
3
4
5
6
7
8
9
10
class TypeTreeNode {
string type;
string name;
int byteSize;
int index;
int metaFlag;
int version;
int depth;
bool isArray;
}
  • type:字段的类型名称(如 int, float, Vector3, string)。
  • name:字段名称。
  • byteSize:该字段在内存中的大小。
  • index:字段在TypeTree中的顺序索引。
  • metaFlag:元标志位(控制序列化行为,例如是否为Align16等)。
  • version:该字段引入的版本(可用于版本判断)。
  • depth:字段在嵌套结构中的深度(用于还原结构树)。
  • isArray:是否是数组类型字段。

5. ObjectInfo

表示 .assets 文件中的某个对象的基本信息。

1
2
3
4
5
6
7
8
class ObjectInfo {
long byteStart;
int byteSize;
int typeID;
ushort classID;
short scriptTypeIndex;
ulong pathID;
}
  • byteStart:该对象数据在文件中的偏移。
  • byteSize:数据块的大小。
  • typeID:序列化类型在类型表中的索引。
  • classID:类型的ClassID。
  • scriptTypeIndex:用于脚本类的索引。
  • pathID:唯一标识该对象的ID(可用于引用)。

6. LocalSerializedObjectIdentifier

描述某个对象在当前 .assets 文件中的引用信息。

1
2
3
class LocalSerializedObjectIdentifier {
long localFileID;
}
  • localFileID:引用对象的 pathID

7. FileIdentifier

用于跨文件引用对象时的文件信息。

1
2
3
4
5
class FileIdentifier {
string assetPath;
GUID assetGUID;
long localFileID;
}
  • assetPath:引用文件的路径(在Editor中可显示)。
  • assetGUID:引用资源的GUID。
  • localFileID:在目标文件中的对象标识符(pathID)。

这些结构一起组成了 Unity 序列化格式的核心结构。

8. SerializedFile文件的结构

下图展示了SerializedFile文件的结构示意图。该结构遵循 Unity 的序列化文件格式,一般用于 .assets.sharedAssets.bundle 中的资源文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
+-------------------------+
| SerializedFileHeader |
+-------------------------+
| Metadata Section |
| |
| +---------------------+ |
| | SerializedType[] | |
| | - classID | |
| | - typeTree | |
| | - scriptTypeIndex | |
| +---------------------+ |
| |
| +---------------------+ |
| | ObjectInfo[] | |
| | - pathID | |
| | - byteStart | |
| | - byteSize | |
| +---------------------+ |
| |
| +---------------------+ |
| | ScriptTypes (opt) | |
| +---------------------+ |
| |
| +---------------------+ |
| | ExternalReferences | |
| | FileIdentifier[] | |
| | LocalSerialized... | |
| +---------------------+ |
+-------------------------+
| Data Section (binary) |
| Raw serialized objects |
| aligned & typed |
+-------------------------+
  • SerializedFileHeader:标记整个文件的版本、大小、数据偏移和字节序。

  • Metadata Section:所有类型信息、对象信息、引用信息等元数据。

    • SerializedType[]:包含该文件中所有用到的类型的结构(包括TypeTree)。
    • ObjectInfo[]:文件中所有对象的地址、大小、类型等信息。
    • ScriptTypes:如果有MonoBehavior等脚本资源,会引用脚本GUID等。
    • FileIdentifier[]:跨文件引用(external object)用的外部资源列表。
  • Data Section:资源本体,存放贴图、Mesh、声音等对象的实际数据。

可根据 ObjectInfo.byteStartObjectInfo.byteSize 从 Data Section 中读取具体对象内容,并通过 TypeTree 解析其结构。

编辑器 YAML 模式与打包二进制格式的转换关系

  • 编辑器开发阶段:可启用 Force Text 模式,使资源以 YAML 格式存储,便于版本控制与协作审阅。
  • 打包阶段(BuildPlayer / AssetBundle):所有 YAML 文本会被自动转化为二进制序列化形式,以提高加载性能和压缩比。

最终发布版本中的所有资源都以 Unity 自定义的二进制格式存储,并不再保留 YAML 表达。

AssetBundle文件结构

AssetBundle 是一个各种资源序列化后的集合,包括脚本、纹理、模型、音频等资源。 .unity3d.bundle 等资源打包文件时,会使用 BundleFile 的结构。它遵循 Unity 的 UnityFSUnityRaw 等打包格式。以下是其中关键结构体 HeaderStorageBlockNode 的字段详细说明及其作用。

Bundle 文件的开头部分,描述整个文件的基本结构和偏移信息。

1
2
3
4
5
6
7
8
9
10
class BundleFileHeader {
string signature; // 格式标识,如 "UnityFS" 或 "UnityRaw"
uint version; // 文件格式版本号(如 6, 7, 8)
string unityVersion; // 构建该Bundle的Unity版本(如 2020.3.0f1)
string unityRevision; // 精确版本号和修订信息
ulong size; // 整个bundle文件的大小
uint compressedBlocksInfoSize; // BlocksInfo的压缩大小
uint uncompressedBlocksInfoSize; // BlocksInfo的解压后大小
uint flags; // 标志位,控制是否压缩、是否嵌入BlocksInfo等
}

字段说明:

  • signature:表示打包格式,常见为 UnityFS,决定了后续字段的解析方式。

  • version:格式版本,不同版本字段解释有细微差异。

  • unityVersion / unityRevision:提供构建信息,用于兼容性判断。

  • size:整个 bundle 文件长度,用于验证文件完整性。

  • compressedBlocksInfoSize / uncompressedBlocksInfoSize:紧随其后的 BlocksInfo 数据块大小(压缩前后)。

  • flags

    • 位 0:BlocksInfo 是否嵌入到 header 后部(否则在文件尾部)。
    • 位 1:BlocksInfo 是否经过压缩(如 LZ4, LZMA)。

2. StorageBlock

描述 bundle 文件中每一个数据块的压缩与存储方式。

1
2
3
4
5
class StorageBlock {
uint uncompressedSize; // 原始大小
uint compressedSize; // 压缩后大小
ushort flags; // 压缩方式标记(如 None, LZMA, LZ4)
}

字段说明:

  • uncompressedSize:该数据块在解压后的字节数。

  • compressedSize:实际在 bundle 文件中存储的压缩数据大小。

  • flags:标志当前数据块使用的压缩算法:

    • 0x00:无压缩(None)
    • 0x01:LZMA
    • 0x02:LZ4
    • 0x03:LZ4HC

这些数据块会依序排列,拼接出完整的资源数据流。

3. Node

描述 bundle 中的虚拟文件结构(例如一个 .assets 文件或其他资源)。

1
2
3
4
5
6
class Node {
long offset; // 在数据块拼接后的偏移(解压后流中的偏移)
long size; // 文件大小
uint flags; // 文件标志(通常未使用)
string path; // 资源名称或虚拟路径
}

字段说明:

  • offset:该虚拟文件在整个资源流(解压后的数据拼接体)中的偏移位置。
  • size:该文件的长度。
  • flags:通常恒为0,可忽略。
  • path:在 bundle 中定义的资源文件名称。例如:CAB-xxxxxx,或 sharedassets0.assets

4. Bundle文件结构图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
+------------------------------+
| BundleFileHeader |
| - signature |
| - version |
| - unityVersion |
| - unityRevision |
| - size |
| - compressedBlocksInfoSize|
| - uncompressedBlocksInfoSize|
| - flags |
+------------------------------+

+------------------------------+
| Compressed BlocksInfo | ← 可选:有些版本在文件尾部
+------------------------------+
↓ 解压后
+------------------------------+
| StorageBlock[] | ← 描述接下来的压缩数据块结构
+------------------------------+
| Node[] | ← 描述解压数据中的虚拟文件信息
+------------------------------+

+------------------------------+
| Compressed Data Blocks | ← 实际资源数据(按 StorageBlock 分段)
+------------------------------+
↓ 解压拼接后
+------------------------------+
| [Node 1 Content] |
+------------------------------+
| [Node 2 Content] |
+------------------------------+
| ... |
+------------------------------+

Addressables

Unity 的 Addressables 系统是 Unity 提供的一套高级资源管理与异步加载框架,它基于 AssetBundle 构建,但提供了更灵活的资源打包、定位与生命周期管理机制。以下从组件结构、实现机制和与传统 AssetBundle 的差异三个方面进行详细说明。

Addressables 的主要组件

  1. Addressable Asset Settings (地址配置)
  • 位于 Assets/AddressableAssetsData
  • 包含全局配置(如构建平台、资源定位方式、Profiles 设置等)。
  1. AddressableAssetGroup
  • Addressable 的资源分组单位。

  • 每组可以设置独立的打包策略、加载路径、构建方式。

  • 常见的 Group 类型:

    • Static Content:固定内容,随包一同发布。
    • Remote Content:远程 CDN 加载资源。
  1. AddressableAssetEntry
  • 每个被标记为 addressable 的资源,对应一个 Entry。
  • 包含资源路径、地址名、Label 等属性。
  1. Profile Settings
  • 支持设置多个环境(开发、测试、发布)下的变量。
  • 如:RemoteLoadPath = http://cdn.mycompany.com/[BuildTarget]
  1. Content Catalog
  • 构建时生成的 JSON 文件,描述所有资源的位置与依赖信息。
  • 加载 Addressable 时,先加载 Catalog。
  1. ResourceLocator
  • Catalog 解析后生成的结构,负责将地址映射为资源路径。
  • Addressables.LoadAssetAsync() 内部依赖它完成地址到路径的解析。
  1. ResourceManager
  • Addressables 底层的加载调度中心。
  • 管理加载任务、依赖树、引用计数、缓存。
  1. IResourceProvider
  • 抽象加载提供器接口。
  • 可自定义如从网络、本地磁盘、WebGL 缓存中加载资源。

Addressables 与 AssetBundle 的区别

  1. 资源定位机制
  • AssetBundle:通过资源路径或 Bundle 名称定位资源,依赖关系由开发者显式管理。
  • Addressables:通过地址系统与 Catalog 文件进行统一索引,通过 IResourceLocator + 哈希索引管理资源映射,解耦了资源名与加载方式。
  1. 加载调度系统
  • AssetBundle:需手动管理 AssetBundle 加载、依赖、释放、缓存。
  • Addressables:封装在 ResourceManagerAsyncOperationHandle 中,自动管理引用计数、自动卸载、自动释放依赖。
  1. Catalog + RuntimeData
  • Addressables 构建生成的 catalog_xxx.json 是核心索引文件,记录资源地址、哈希、依赖、提供器类型。
  • RuntimeData 是构建期生成的内部数据结构,包括本地清单、远程路径映射、默认Provider绑定信息。
  1. 提供器架构(Provider)
  • Addressables 使用 IResourceProvider 接口(如:BundledAssetProvider、TextDataProvider、AtlasSpriteProvider 等)支持加载多种资源类型。
  • 每种资源类型可配置独立 Provider,也可扩展自定义 Provider 支持版本控制、加密等。
  1. 构建流程
  • Addressables 构建流程为:分析分组 -> 计算依赖 -> 构建 AssetBundle -> 生成 Catalog 和链接关系 -> 可选生成 BuildLayout 文件(用于调试和分析)
  • 相比之下,AssetBundle 仅构建资源和依赖 Bundle,没有结构化 Catalog 文件。
  1. 远程与热更新机制
  • Addressables 提供 CheckForCatalogUpdatesDownloadDependenciesAsync 等接口直接进行差异比对与远程下载。
  • 核心机制是基于 Catalog 哈希比对与本地缓存标记,而传统 AssetBundle 需开发者手动构建下载/更新逻辑。

常用 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 通过地址加载资源
Addressables.LoadAssetAsync<GameObject>("PlayerPrefab");

// 加载并实例化
Addressables.InstantiateAsync("Enemy");

// 卸载资源
Addressables.Release(handle);

// 获取多个带标签的资源
Addressables.LoadAssetsAsync<GameObject>("enemy", callback);

// 同步释放实例对象
Addressables.ReleaseInstance(instance);

// 通过标签加载多个资源(异步)
Addressables.LoadAssetsAsync<GameObject>(new List<object> { "environment", "npc" }, obj => {
Debug.Log("加载完成:" + obj.name);
});

// 通过资源名称加载(资源名称即为 key 或地址)
string address = "MyUI/StartButton";
Addressables.LoadAssetAsync<GameObject>(address).Completed += handle => {
if (handle.Status == AsyncOperationStatus.Succeeded)
{
GameObject go = handle.Result;
GameObject.Instantiate(go);
}
};

构建与热更新使用流程示例

  1. 构建 Addressables 内容(在编辑器中)
1
2
// 编辑器中构建 Addressables
AddressableAssetSettings.BuildPlayerContent();
  1. 检查 Catalog 更新与远程热更新资源
1
2
3
4
5
6
7
8
9
10
11
12
// 检查是否有 Catalog 更新
var checkHandle = Addressables.CheckForCatalogUpdates();
checkHandle.Completed += handle => {
if (handle.Result.Count > 0) {
// 下载更新后的 Catalog
var updateHandle = Addressables.UpdateCatalogs(handle.Result);
updateHandle.Completed += catalogHandle => {
// 可在此下载资源依赖
Addressables.DownloadDependenciesAsync("MyRemoteLabel");
};
}
};
  1. 工程中加载远程资源的完整流程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
IEnumerator LoadRemoteAsset(string key)
{
// 预下载依赖资源
var download = Addressables.DownloadDependenciesAsync(key);
yield return download;

// 异步加载资源
var handle = Addressables.LoadAssetAsync<GameObject>(key);
yield return handle;

if (handle.Status == AsyncOperationStatus.Succeeded)
{
GameObject obj = handle.Result;
// 实例化
GameObject instance = GameObject.Instantiate(obj);

// 使用完毕后销毁实例
Addressables.ReleaseInstance(instance);
}

// 卸载依赖(可选)
Addressables.Release(handle);
}
  1. 加载本地资源
1
2
3
4
5
6
7
8
9
10
11
// 本地路径加载方式相同,Addressables 自动判断是否为本地 Bundle
var handle = Addressables.LoadAssetAsync<GameObject>("LocalAssetKey");
handle.Completed += h => {
if (h.Status == AsyncOperationStatus.Succeeded)
{
var go = GameObject.Instantiate(h.Result);
// 使用完成后可销毁并释放资源
Addressables.ReleaseInstance(go);
Addressables.Release(h);
}
};

常用资源

核心资源类型划分与内存模型

Unity 支持的资源种类涵盖从图形渲染、音频播放到逻辑控制的方方面面,其常见分类及用途如下:

  1. Texture2D / Texture3D / Cubemap:图像纹理资源,用于 2D/3D 贴图、天光、环境映射等。
  2. Mesh:模型资源,包含顶点坐标、法线、UV、切线、索引等几何结构数据。
  3. AnimationClip:关键帧动画与曲线数据集合,驱动模型变换、骨骼动画等。
  4. AudioClip:用于存储原始音频数据,支持 PCM、ADPCM、Vorbis 等格式。
  5. Shader / Material:图形着色语言代码与其参数封装体,负责控制表面渲染效果。
  6. Font:字体资源,支持 TrueType、OpenType 等格式。
  7. TextAsset:存储任意文本或二进制文件内容的通用资源。
  8. VideoClip:视频数据资源,供 VideoPlayer 播放使用。
  9. Sprite:2D 图像切片,常用于 UI 元素或 2D 动画。
  10. Prefab:预制体资源,是组件与 GameObject 层级结构的序列化封装。
  11. Scene:场景资源,描述场景中所有对象的状态与引用。
  12. ScriptableObject:自定义数据容器,广泛用于游戏逻辑配置。
  13. AnimatorController / StateMachine:动画控制器与状态机配置资源。

这些资源在运行时均由 C++ 层的原生结构(NativeObject)表示,其 C# 封装对象中通常包含一个 m_CachedPtr 字段,指向真实的数据结构体。

资源导入器 Importer 与转换管线

Unity 的 Importer 系统负责解析外部资源格式,并将其转换为统一的中间表示与持久化形式。不同类型的资源使用不同的 Importer 进行处理。以下是典型资源的转换路径:

Importer 支持的原始格式 配置结构(来自 .meta) 中间资源结构(Library 存储)
TextureImporter .png, .jpg, .tga, .dds TextureImporterSettings + TextureSettings Texture2D + StreamingInfo
ModelImporter .fbx, .obj, .dae RigImportSettings + MeshImportSettings Mesh / Avatar / AnimationClip
AudioImporter .mp3, .wav, .ogg AudioImporterSettings AudioClip
ShaderImporter .shader ShaderImportSettings Shader + ShaderSubProgram
AnimationClipImporter .anim AnimationClipSettings + CurveMappings AnimationClip
VideoClipImporter .mp4, .mov VideoImporterSettings VideoClip
ScriptImporter .cs MonoImporterSettings MonoScript

导入完成后的资源对象会被序列化并缓存至 Library/ 目录,以优化后续构建和编辑流程。

NativeObject 内存结构分析

所有 Unity 的 C# 资源对象(如 Texture2D)底层都是对原生 C++ 结构体的托管包装,其核心字段 m_CachedPtr 指向内存中的实际数据。

以 Texture2D 为例,其底层结构类似如下:

1
2
3
4
5
6
7
struct Texture2D : Texture {
int m_Width;
int m_Height;
TextureFormat m_Format;
StreamingInfo m_StreamData;
// 其他字段略
};

运行时,Unity 的对象系统维护一张对象表,用于管理所有原生对象的生命周期、引用关系及其与托管对象的映射。

内存

PC 内存架构与管理机制

架构特性

  • 分离内存结构:PC 通常配备独立 CPU 主内存(DDR4/DDR5)与 GPU 专用显存(GDDR),资源在 CPU 与 GPU 间通过 PCIe 总线传输,需注意显存带宽和 Host-to-Device 的数据拷贝开销。
  • 地址空间更大:64 位操作系统提供大于 4GB 的虚拟地址空间。现代 Windows/Linux 提供每进程高达 TB 级别的虚拟空间。
  • NUMA(非一致性内存访问):多核 CPU 常见于服务器/桌面端,存在多个物理内存节点,跨 NUMA 节点访问将增加延迟。

操作系统内存管理

  • 分页系统:操作系统使用分页机制管理内存,常用 4KB 页,并支持大页(Huge Page)。通过页表(Page Table)实现虚拟地址到物理地址的映射。
  • 页面换出(Swapping):在内存不足时,系统将部分内存页写入磁盘(Swap File/Swap Partition),释放物理内存用于高优先级任务。Unity 应用在 Swap 下运行可能出现显著卡顿或帧率下降。
  • 内存保护机制:通过页属性标识读/写/执行权限,防止非法访问,如访问空指针或释放后的内存会触发“段错误”(Segfault)。

手机内存硬件与系统层

操作系统的内存申请、使用与释放流程

  • 内存申请(Allocation):操作系统通过页分配器(如 vm_allocate on iOS, mmap on Linux/Android)按页为应用分配虚拟地址空间,通常以 4KB 页为单位。分配时可能并未实际占用物理内存,只有在访问该页时(写入)才会触发“页错误”并分配真实物理页(按需分配策略)。
  • 内存使用(Access):当应用访问虚拟内存时,操作系统通过页表(Page Table)和 TLB(Translation Lookaside Buffer)将虚拟地址映射到物理地址。部分内存页可能被标记为只读或共享页,以提高安全性与效率。
  • 内存释放(Deallocation):当进程释放内存(如通过 free()munmap() 或系统层回收机制),对应虚拟页会被从页表中移除,物理页被标记为“可回收”。在某些平台上,这些页可能仍暂时保留(作为缓存),直到系统主动回收。
  • 后台回收与内存压力响应:在内存压力高时,系统会回收不活跃页(Inactive Pages)、清除 Cache、或者直接终止后台进程(如 Android 的 LMK,iOS 的 Jetsam)。内存释放过程是懒惰式的,尽可能避免频繁回收和再次分配。

iOS 内存架构与管理机制

  • 统一内存架构:iOS设备通常采用Unified Memory Architecture (UMA),CPU 和 GPU 共享 LPDDR 系统内存,无需显式拷贝资源至独立显存,有利于资源访问效率,但也意味着 GPU 消耗的内存直接挤占 CPU 可用内存。
  • 无Swap机制:iOS 虽使用虚拟内存地址系统,但不允许 swap 到磁盘,所有内存申请都必须在物理RAM范围内完成。
  • 内存警告机制:应用若占用过多内存,系统会触发 UIApplicationDidReceiveMemoryWarningNotification 或 SwiftUI 的 @Environment(\.memoryWarning)。若未及时响应释放资源,系统可能直接终止应用。
  • 后台内存淘汰策略:在后台,iOS 会依据 memory pressure 使用 Jetsam 策略驱逐后台进程。Jetsam 是 Darwin 内核中 OOM 终结者,对内存压力进行 PID 层级逐步回收。

Android 内存架构与管理机制

  • 设备差异化严重:Android 设备从低端 512MB 到高端 16GB RAM 均有,需针对低端设备优化路径分离。
  • Zygote预加载机制:Android 使用 Zygote 机制共享系统框架库,在进程fork时减少初始化成本和内存消耗。
  • GC管理机制:ART VM 默认采用并发分代垃圾回收(Concurrent Generational GC),分为 Eden、Survivor、Old 区,GC Pause Time 控制至 10ms 以内。大对象(>256K)直接进入老年代。
  • 内存回收接口:应用生命周期中可通过 onTrimMemory() 钩子处理如 TRIM_MEMORY_BACKGROUND / RUNNING_LOW 等状态,及时释放缓存资源。
  • 堆限制查询:可通过 ActivityManager.getMemoryClass()getLargeMemoryClass() 获取堆上限(如 128MB, 256MB 等)。

Unity引擎内存结构与使用方式

Unity 内存划分详解

  • 托管内存(Managed):C# 层对象,如 GameObject、MonoBehaviour、ScriptableObject 等,受 GC 控制。GC 不可预期,建议使用对象池避免频繁触发。
  • 原生内存(Native):Unity 内核层 C++ 数据结构:渲染、粒子系统、动画状态机、NavMesh 等。无法被 C# 的 GC 控制,需手动释放资源(如 Mesh、Texture、ComputeBuffer)。
  • 图形内存(Graphics/GPU):上传到 GPU 的资源,包括贴图、Mesh、ShadowMap、RenderTexture 等。在 OpenGL ES / Metal / Vulkan 下共享系统内存,容易与其他内存抢占资源。
  • 临时内存(Temp Allocator):帧内临时使用,如命中测试、物理检测、Render Loop中间态等,大小固定(4MB),超出将退化为 Heap Alloc,导致 GC Alloc 增加。

Unity 移动端内存行为

  • Memory Profiler 分类:分为 Total Reserved、Total Used、Mono、Gfx、Other,便于定位问题。
  • IL2CPP 与 Mono 差异:IL2CPP 性能更好、GC 控制更紧,代码大小增加,Native 内存增加;Mono 适合编辑器或开发测试环境。
  • 默认内存策略:资源尽量异步加载、使用 Addressables 精细控制生命周期,避免热区资源常驻内存。

Unity 开发中的内存优化技巧

纹理优化

  • 压缩格式选择

    • iOS:推荐使用 ASTC(质量/大小可控);老设备兼容使用 PVRTC(压缩质量低)。
    • Android:优先 ASTC,其次 ETC2,最低 ETC1(无 Alpha)。Unity 可通过平台导入设置区分纹理压缩格式。
  • MipMap 与 StreamingMip:开启 Mipmap 可节省 GPU 带宽;结合 Texture Streaming 可按需加载低分辨率贴图,显著降低内存占用。

  • Atlas 图集管理:将小纹理合并为大图集可减少材质切换和 Draw Call,但需控制尺寸,避免合图过大导致 StreamingMip 效率低下。

网格与渲染优化

  • Mesh Compression:在导入设置中开启 Mesh Compression(如 Medium/High)减少顶点数据占用。

  • 静态合批与动态合批

    • Static Batching:占用更多内存,但极大减少 Draw Call。
    • Dynamic Batching:限制顶点数 < 900;小物体适合。
  • GPU Instancing:对重复物体使用 Instancing 替代合批,节省 CPU → GPU 的通信开销。

  • 遮挡剔除/Frustum Culling:关闭不可见对象渲染,降低 GPU 运算压力与带宽使用。

动画系统优化

  • Animator Culling Mode:设置为 Cull Update Transforms 可在角色不在视野时停止动画更新。
  • Bone 限制:控制单角色骨骼数,限制每顶点绑定骨骼数量(<4)。
  • 动画压缩设置:启用 Optimal 压缩方式,移除冗余关键帧,减少曲线数据。
  • Bake 动画:将运行时动画 Bake 到 Transform 减少实时计算。

Addressables 使用技巧

  • 生命周期管理

    • 每次 LoadAssetAsync 后应配对 Addressables.Release;否则引用计数未清零,导致内存泄漏。
  • 异步加载场景与资源:使用 Addressables.LoadSceneAsync / InstantiateAsync 异步加载,减少卡顿;场景切换完成后手动 UnloadSceneAsyncRelease

  • 多平台资源分包:资源按平台/分辨率拆包(如 HD/SD),避免高配资源在低端设备加载。

GC与堆内存优化

  • 减少GC Alloc

    • 避免每帧字符串拼接、LINQ。
    • 使用对象池代替频繁 Instantiate/Destroy。
    • 使用 Struct 替代 Class 避免堆分配。
  • 增量GC(Incremental GC):Unity 2019+ 支持,分帧执行 GC,避免卡顿尖峰。

  • Memory Profiler:定期生成快照,比较引用链定位泄漏路径(如 ScriptableObject 没有正确释放)。

工具链

  • Unity Profiler:观察 GC Alloc、Native Mem、Gfx Mem 曲线,分析每帧内存使用。
  • Memory Profiler(Package):快照对比、引用路径分析。
  • Android Profiler / Xcode Instruments:分析 Native 层使用、Java 层 GC/Leaks。
  • ADB shell:结合 lowmem-killer/stressapptest 模拟 OOM 场景。

平台差异与特别注意事项

Android 特有限制

  • 部分 Android Go 设备堆上限仅 128MB,务必支持资源降级。
  • 部分 GPU(如旧 Mali)不支持 ASTC,需动态切换纹理格式。
  • Android 的 RenderTexture 默认内存常驻,需主动销毁。

iOS 特性优化

  • 使用 RenderTextureMemoryless.Depth 减少 Tile Memory 压力。
  • iOS Metal 统一内存访问快,但若分辨率过高,GPU 资源争抢更激烈。
  • 使用 OnLowMemory() 钩子及时清理缓存资源,防止 Jetsam Kill。

代码优化(CPU时间)

明白了。我将整理一份针对 Unity 中使用 C# 和 XLua 的 CPU 优化方案,适用于 3D SLG 游戏,覆盖 PC、iOS 和 Android 平台,并对比 Mono 与 IL2CPP 的差异,同时包括 Unity Profiler 工具在优化过程中的使用建议。 请稍等片刻,我会尽快为你准备好详细内容。

常见C#脚本瓶颈及优化建议

  • GetComponent/查找:频繁调用 GetComponentFindCamera.main 等会遍历对象池,非常耗时,应在 Awake/Start 中缓存引用。如下例所示,将 transform 缓存到字段中避免重复查找:

    1
    2
    3
    4
    private Transform _transform;
    void Awake() {
    _transform = transform; // 缓存 Transform
    }

    对于 Camera.main、标签查找等,同样应缓存或在 Inspector 里引用。

  • Update 调用开销:每个挂 UpdateMonoBehaviour 都会产生管理开销,数量过多时影响极大。当游戏中数百或上千个物体需要每帧更新时,建议使用全局管理器统一调度,而不是每个对象独立 Update。如 Unity 官方建议,将需要更新的对象注册到单例管理器中,由管理器在 Update 中遍历调用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class UnitManager : MonoBehaviour {
    private static readonly List<Unit> _units = new List<Unit>();
    public static void Register(Unit u) { _units.Add(u); }
    public static void Unregister(Unit u) { _units.Remove(u); }
    void Update() {
    float dt = Time.deltaTime;
    foreach (var u in _units) u.CustomUpdate(dt);
    }
    }
    public class Unit : MonoBehaviour {
    void OnEnable() { UnitManager.Register(this); }
    void OnDisable() { UnitManager.Unregister(this); }
    public void CustomUpdate(float dt) { /* 单位逻辑 */ }
    }

    这样能避免大量原生到托管回调切换的开销。

  • 委托和事件:C#的委托在添加/移除回调时会复制内部列表,频繁的订阅/取消订阅会造成大量开销。对于高频率的事件管理,尽量避免直接使用 delegate;可以改用自定义的可快速插入/删除的数据结构(如 List<Action> 或用户管理的事件分发机制)。在更新管理器中使用委托订阅时要注意,如果每帧动态添加/移除,性能会急剧下降。

  • 反射:反射调用极慢,比直接调用慢数百到上千倍。优化思路是绕开反射:可预先缓存 MethodInfo/FieldInfo,或使用 Delegate.CreateDelegateExpression 树等生成委托来调用。注意 IL2CPP 模式下不支持 IL.Emit 和动态表达式,最好改用委托或指针操作。总之,能用常规代码解决的场景尽量不要使用反射,若必须使用,要做缓存处理。

  • 结构体与装箱:频繁使用值类型(struct)或属性访问可能带来隐性开销。例如反复使用 Vector3 自带字段可能会装箱。优化建议是将多个值型参数打平成静态方法,避免在Lua或 C# 侧创建额外对象。例如:

    1
    2
    3
    4
    5
    6
    public static class TransformUtil {
    // C#静态方法一次性设置坐标,比Lua逐字段赋值快得多
    public static void SetXYZ(this Transform t, float x, float y, float z) {
    t.position = new Vector3(x, y, z);
    }
    }

    XLua调用时 transform:setXYZ(x,y,z) 的效率比 transform.position = {x=..} 高数倍。

  • 内存分配与GC:运行时大量分配会触发 GC,影响帧率。应尽量避免在 Update 或热路径中新建对象。例如,不要每帧 new List<>() 或拼接字符串,而应复用容器或使用对象池。如下示例,将列表提前创建并清空:

    1
    2
    3
    4
    5
    6
    7
    private static readonly int listCapacity = 100;
    private readonly List<int> _list = new List<int>(listCapacity);
    void Update() {
    _list.Clear();
    for(int i=0; i<listCapacity; i++) _list.Add(i);
    // … 使用_list
    }

    这样避免了每帧构造新列表的 GC.Alloc。另外,应小心使用 foreach 遍历(旧版本Unity会因 Enumerator 装箱导致 GC),常规可改用 for 循环或缓存迭代器。

  • Lambda与闭包:在 Lambda 表达式中捕获局部变量或实例字段会产生闭包对象,触发额外分配。尽量避免频繁在回调中使用捕获局部变量,如不需要可用静态变量替代。

  • 其他注意点:避免每帧生成大量字符串(可用 StringBuilder)、禁用未使用的组件或脚本、合理拆分场景避免运行时加载过多对象等。

XLua脚本性能优化

  • C#⇆Lua 交互开销:XLua 等桥接方案每次从Lua侧访问C#对象时,都会经过 ObjectTranslator 的查表、推栈/取值等过程,开销极大。例如 gameobj.transform.position = pos 这样的调用在Lua中会经历多次翻译和分配,导致大量 CPU 时间和 GC 分配。优化方法是减少跨语言调用次数:

    • 缓存C#对象:将常用 C# 对象保存在Lua局部变量中,避免重复查找。
    • 静态函数替代成员:尽可能将逻辑封装为静态方法导出,避免在Lua中每次访问实例成员都要做对象查找。比如写成 LuaUtil.SetPos(obj, x,y,z) 用原生C#方法设置位置,可省去transform中间对象的反复创建,提升明显。
    • 减少参数/返回类型复杂度:尽量使用基本类型作为参数和返回,避免在Lua和C#之间频繁传递 Unity 特有的值类型(如 Vector3Quaternion)或数组,因为它们需要多次栈操作和内存分配。例如,把 void SetPos(GameObject obj, Vector3 pos) 拆成 void SetPos(GameObject obj, float x, float y, float z),从测试来看会快得多。
  • 值类型与GC优化:XLua支持在C#代码中为值类型加 [GCOptimize] 属性,用来优化 C#<–>Lua 之间的值类型传递。带 GCOptimize 的普通值类型(纯值域的 struct、枚举及其数组)在传递时可以避免 GC 分配。建议对需要频繁在Lua中传递的自定义 struct 添加 [GCOptimize][LuaCallCSharp] 标记。

  • Lua脚本本身优化:在Lua侧也要尽量减少中间创建。使用局部变量而不是全局,每帧循环中避免用table.insert等高开销操作;对于大量运算可考虑在C#侧提前实现为函数,Lua只作函数调用。避免在Lua里频繁创建临时表或字符串。

  • 全局变量与引用:注意Lua持有的C#对象引用会阻止C#垃圾回收。应及时将不再使用的Lua全局置为 nil,或者手动调用 xlua.hotfix(GO,"OnDestroy") 断开引用,防止内存泄漏。

语言特性对性能的影响

  • 委托与事件:如上所述,C# 委托在增删回调时会复制列表,事件订阅过多会拖慢性能。对于热路径或大量订阅,考虑采用自定义事件系统或静态回调列表以避免频繁复制。
  • 反射:反射是一项非常昂贵的操作,要在性能敏感场合尽量避免。必要时可缓存反射得到的 MethodInfo/PropertyInfo,或者使用 DelegateExpression 等方式预编译调用。记住 IL2CPP 不支持运行时动态生成代码(如 Expression.Compile()IL.Emit),所以优化时优先考虑委托或原生写法。
  • GC 与内存分配:Unity 的垃圾收集分两代,需要关注分配量。经常会在 Profiler 中看到的 GC.Alloc 源头包括:循环中新建对象、字符串连接、LINQ、闭包、装箱以及 UI 动态生成等。优化要点是减少分配次数:使用对象池、复用集合(List.Clear() 循环复用)、避免频繁 string 拼接(可用 StringBuilder)、慎用 LINQ(会产生临时对象)。例如,每帧产生的垃圾越少,GC 越不频繁,帧率越稳定。
  • LINQ 和 lambda:LINQ 查询和 new 表达式常创建新对象;Lambda 捕获也会分配闭包实例。对 闭包 来说,如果引用的是局部或实例变量,则会分配;引用静态变量则不会。避免高频场景下使用这些特性。
  • 循环遍历:在 Unity 旧版本中,foreach 可能会因生成枚举器而装箱导致 GC。推荐对简单集合使用 for(int i=0; i<list.Count; i++),并将 Count 缓存到局部变量,降低开销。
  • 方法调用开销:C# 的虚方法调用比静态调用开销略高。对少量但频繁调用的简单方法,可考虑使用 sealed 类、static 方法或 [MethodImpl(MethodImplOptions.AggressiveInlining)](在 IL2CPP 中可启用 C++ 级内联)来减少调用开销。此外,上述列表遍历示例也显示,多层属性和方法调用会带来多重函数调度成本。
  • 数据结构:少用二维或多维 C# 数组(type[,]),因为其内部访问需要额外函数开销,性能远低于交错数组(type[][])。对性能敏感的大量数据使用,应尽量使用一维数组或简单结构。

Mono 与 IL2CPP 的差异

  • 编译方式:Mono 后端使用JIT(即时编译)在运行时将IL编译成本机码,灵活支持热更新和反射;IL2CPP 则是 AOT(提前编译)将 C# 转换为 C++ 再编译,生成本地机器码。IL2CPP 去除了运行时的JIT开销,对多线程和移动平台(尤其是iOS)具有性能和兼容优势。在移动端实践中,IL2CPP 通常比 Mono 运行更快(但编译时间长、包体更大),因此生产环境建议使用 IL2CPP,开发阶段可用 Mono 加快迭代。iOS 平台强制要求使用 IL2CPP(禁止 JIT),Android 平台支持 Mono 32 位和 IL2CPP 64 位。
  • 性能差异:总体上,IL2CPP 经预编译优化后执行效率更高。一些论坛和测评也发现 IL2CPP 在低端机型上表现更好,但实际性能还需根据具体场景基准测试(部分特殊情况中 IL2CPP 可能略慢)。需要注意,IL2CPP 的 AOT 特性限制了某些动态功能:如动态生成代码(IL.EmitExpression.Compile)不可用,反射性能与 Mono 相近但缺少 JIT 优化。
  • 调优技巧:在 IL2CPP 下,虚调用去虚拟化可以带来性能提升。将没有继承需求的类或方法标记为 sealed,可让编译器使用直接调用代替虚表调用。从 Unity 2020.2 起,还可以用 [MethodImpl(MethodImplOptions.AggressiveInlining)] 强制 C++ 端内联,以减少函数调用和参数复制成本。对于性能敏感的数学函数,可考虑内联或手写高效版本。相比之下,Mono 模式下可用 Hot Reload、原生调试,但无法在 iOS 上运行,仅建议用于快速迭代测试。

性能分析工具与方法

  • Unity Profiler:在编辑器或真机(必须为开发版)上运行性能分析。使用 CPU Usage 模块查看各部分开销,按类别(渲染、脚本、物理、GC等)归纳每帧时间消耗。选择某一帧后,在 Profiler 的 “详细信息” 面板里切换 TimelineHierarchy 视图:Timeline 显示该帧内各线程的时间轴,可对比主线程与作业线程的并行关系;Hierarchy 则以层次结构列出所有函数调用及耗时,默认按耗时降序,有助于快速定位耗时热点。

  • 深度性能剖析:启用 Deep Profiling Support 后,Profiler 会对所有脚本函数进行采样,而非仅限 ProfilerMarker 标记的代码。这使得启动等阶段的分析更全面,但会带来额外开销。一般先在核心流程确认大致瓶颈,再在必要时对某几个函数启用深度剖析。分析时可在 Profiler 视图中点击 GC Alloc 等条目,查看调用堆栈,找出垃圾产生的源头。

  • Timeline视图:在 Profiler CPU 模块的 Timeline 视图中,可以观察每帧各线程的活动分布。比如可直观看到主线程在等待 VSync 或 GPU 时间的情况,也可在作业系统场景下查看 Worker 线程何时被调度。熟悉常见的 Profiler 标签(如 Scripts.Update, Physics.Simulate, VSync 等)有助于判断开销归属。

  • ProfilerMarker:在关键代码段使用 Unity.Profiling.ProfilerMarker 标记,可以在 Profiler 的 Timeline/Hierarchy 中显示自定义名称,方便细粒度分析。例如:

    1
    2
    3
    4
    5
    6
    static ProfilerMarker myMarker = new ProfilerMarker("CustomLogic");
    void CustomLogic() {
    using (myMarker.Auto()) {
    // 性能热点代码
    }
    }
  • 其他工具:结合 Timeline Editor(帧捕获工具)分析渲染、GPU 等开销。使用 Memory Profiler 查看内存泄漏。对于网络或 IO,也可使用专门的分析工具。但总体原则是:先用 Profile 找出瓶颈所在,再针对性优化

渲染优化(CPU端)

总体架构概览

Unity 使用基于 Component 的实体系统(GameObject + Component)组织场景中的渲染对象。典型的渲染对象由以下组件组成:

  • Transform:提供位置、旋转、缩放。
  • MeshFilter:持有渲染的 Mesh 数据。
  • MeshRenderer:将网格通过材质渲染到屏幕上。

从 2018.1 起,Unity 支持 Scriptable Render Pipeline(SRP),替代 Built-in 渲染流程。SRP 将渲染控制权下放给开发者(通过 C# 实现的 RenderPipelineRenderPipelineAsset),但底层提交仍由 C++ 实现完成(如 GfxDevice)。

渲染对象的分类与处理规则

Unity 中渲染对象的处理分为几个关键分类维度:

1. 静态 vs 动态

  • 通过 GameObject.isStatic 标志决定是否为静态物体。
  • 静态物体可参与 静态合批(Static Batching),在构建或运行时将多个对象合并为一个网格,大幅减少 Draw Call。
  • 动态物体可使用 动态合批(Dynamic Batching)(受限条件:顶点属性总数 < 900,不能使用多 Pass)。

底层静态合批由 C++ 层的 StaticBatchingUtility 负责,在构建时生成合并网格,并打包成 StaticBatchRoot 节点,运行时通过 StaticBatchRenderer 实现直接渲染。

2. 渲染队列(RenderQueue)

  • 决定绘制顺序,影响透明度处理。

  • 通常划分为:

    • 0–2500:不透明(Opaque)
    • 2501–3000:AlphaTest
    • 3001–5000:透明(Transparent)

底层渲染队列由材质的 shader.renderQueue 决定,在 Material::ComputeRenderQueueWithOffset() 中与 SubShader 的默认值合并处理。

3. 渲染层(SortingLayer + OrderInLayer)

  • 主要用于 2D 渲染排序控制。

对应的底层实现见 Renderer::m_SortingLayerIDm_SortingOrder,在渲染排序中通过 GetFinalSortOrder() 函数与摄像机参数一起参与排序逻辑。

4. SRP 分类流程(以 URP 为例)

在 URP 中分类逻辑主要集中在 ForwardRendererSetup()Render() 中:

  • 使用 ScriptableCullingParameters 对可见对象进行剔除(Frustum Culling + Occlusion Culling)。
  • 结果是 CullingResults.visibleRenderersList
  • DrawRenderers 时,传入 FilteringSettings 控制 Layer、Queue 等筛选。
  • 使用 SortingSettings 指定排序方式(如 opaque 前向,transparent 后向)。

渲染前的处理流程

Unity 渲染对象在通过图形 API(如 DirectX/Metal/Vulkan)提交前经历如下处理流程:

1. Culling 剔除

  • 通过 CullResults.Cull() 进行视锥体剔除(Frustum Culling)。
  • 可启用遮挡剔除(Occlusion Culling),使用预计算的遮挡体(PVS)或动态 GPU 遮挡(如 Umbra)。
  • 剔除逻辑底层通过 SceneCulling.cppCull.cpp 中的 SIMD 加速处理 BoundingBox 与 Frustum 的包围盒交集检测。

2. Filtering 筛选

  • 使用 FilteringSettings 控制:LayerMask、RenderQueueRange、ShaderTag、MotionVector。
  • 对应底层结构为 FilterResults,通过 Renderer::PassesShaderTagId 做匹配。

3. Sorting 排序

  • 使用 SortingSettings 控制排序规则:

    • CommonOpaque:按材质和状态排序,减少状态切换。
    • CommonTransparent:按摄像机距离排序(后向前避免混合错误)。
  • 实现上使用 DrawObjectSortFunctions.cpp 中的排序函数对 RenderList 排序。

4. State Setup 渲染状态准备

  • 包括:设置 Camera matrices、Lighting、Lightmap、Global Shader Constants。
  • 若开启 SRP Batcher:所有材质 Uniform 常量使用统一大 Buffer 上传,减少 CBuffer 切换。
  • 渲染状态在 C++ 中使用 SetGlobalConstantBufferSetPassGlobalState() 设置,最终提交 DrawCallCommand

5. 合批与实例化(Batching & Instancing)

  • 静态合批:提前合并网格,减少 Draw Call,由 StaticBatchRenderer 提交索引偏移进行绘制。
  • 动态合批:CPU 合并顶点后提交。由 DynamicBatching.cpp 实现,运行时构建临时 VBO/IBO。
  • GPU Instancing:相同材质的多个对象通过 Material.enableInstancing 一次性绘制。
  • SRP Batcher:使用固定格式统一结构的 UniformBuffer 进行批处理。

底层由 BatchRendererGroup.cpp 调用 DrawMeshInstanced()DrawInstancedProcedural() 实现。

6. CommandBuffer 构建与提交

  • 所有绘制命令封装为 CommandBuffer(C#)或 ScriptableRenderContext
  • 最终调用 context.Submit(),封装为 RenderCommandBuffer 结构,提交给 C++ GfxDevice。
  • GfxDevice(如 GfxDeviceD3D11/GfxDeviceMetal)最终翻译为底层 API DrawCall 并提交 GPU Command Queue。

优化目标与性能考虑

阶段 目的 优化方法
剔除 减少不必要渲染对象 利用 Job System 多线程剔除,使用 GPU Occlusion Culling
筛选 精准筛选目标对象 使用 FilteringSettings 限制 Layer/Queue
排序 降低 GPU Pipeline flush Opaque 优先按材质排序,Transparent 后向前避免混合错误
合批 减少 Draw Call 数 静态合批/动态合批/GPU Instancing/SRP Batcher
状态 减少 CBuffer 绑定 启用 SRP Batcher(每材质使用统一 buffer)
提交 提高 CPU-GPU 并发效率 使用 CommandBuffer 封装所有命令后统一提交

关键源码位置与类

功能 源码/类
Renderer 渲染组件 UnityEngine.MeshRendererSkinnedMeshRenderer
剔除 CullResultsScriptableCullingParameters,底层见 Cull.cpp
渲染指令 ScriptableRenderContext.DrawRenderers(),底层翻译为 RenderCommandBuffer
批处理 BatchRendererGroup、SRP Batcher(RenderGraph
GPU Instancing Material.enableInstancing = true;Shader 中使用 UNITY_INSTANCING_CBUFFER_START/END
SRP 管线主类 RenderPipeline, RenderPipelineAsset,内部走 RenderPipelineManager.DoRenderLoop_Internal() 调度

网络优化

网络通信整体架构概览

在大型多人联网游戏中,通常采用如下架构:

  • 客户端:Unity + C# + Xlua
  • 服务端:C++/Java/Golang 等高性能语言
  • 通信协议:TCP 为主,部分 UDP 或 WebSocket 用于推送、心跳等
  • 数据格式:ProtoBuf / FlatBuffers / 自定义二进制协议 / JSON(调试)

客户端通信模块的职责包括:

  • 创建 socket 并维护连接
  • 数据收发与缓冲管理
  • 协议解析、打包、粘包处理
  • 异常重连与心跳保活

Socket 通信底层流程(以 TCP 为例)

Socket 创建与连接建立

1
2
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.Connect(ip, port);
  • AddressFamily.InterNetwork: 使用 IPv4 协议
  • SocketType.Stream: 流式 socket,TCP
  • ProtocolType.Tcp: 明确协议

设置 Socket 参数(可优化性能)

1
2
3
socket.NoDelay = true; // 关闭 Nagle 算法,降低延迟
socket.SendBufferSize = 64 * 1024;
socket.ReceiveBufferSize = 64 * 1024;

SendBufferSizeReceiveBufferSize:设置的是 操作系统内核的 socket 缓冲区大小,而不是应用层 buffer。这影响了 socket 层对数据包的缓存能力。

NoDelay = true 用于关闭 Nagle 算法(详见后文),避免发送小包延迟。

建立连接过程(TCP 三次握手)

  1. 客户端发送 SYN
  2. 服务端返回 SYN + ACK
  3. 客户端发送 ACK 确认

网络数据发送流程

发送数据(Send)

1
socket.Send(buffer, offset, length, SocketFlags.None);
  • 底层实际调用 OS 的 send 函数,将数据写入 socket 的 发送缓冲区
  • 若缓冲区满,会阻塞或返回未写完的字节数(异步 IO 会挂起等待)。

数据组织

  • 数据结构通常为:[协议头(长度+类型)] + [消息体]
  • 避免频繁申请大数组,使用 ArrayPool<byte> 或环形缓冲池复用
  • 批量打包小数据,避免碎片化,提高发送效率

Nagle 算法说明

Nagle 算法的目的:减少网络中小包数量,提高带宽利用率

原理:

  • 当 socket 中存在未确认的数据包时,暂不发送新的小包,等待 ACK 后再发。

缺点:

  • 会引起 40~200ms 的发送延迟,尤其在 turn-based 游戏中尤为明显。

建议:

  • 默认关闭(socket.NoDelay = true)提升实时性

网络数据接收流程

接收数据(Receive)

1
socket.Receive(buffer, offset, size, SocketFlags.None);
  • 数据从 OS 的内核 接收缓冲区 拷贝到应用层 buffer。
  • 应用层通常使用独立线程 / 异步回调持续监听数据。

粘包 / 拆包问题

  • TCP 是面向字节流的协议,没有消息边界,需自己处理粘包/半包:

    • 协议设计中加 Length 字段(前 4 字节等)来解包
    • 自己维护缓存区,按 length 解析完整消息

性能优化建议

ArrayPool 的底层实现详解

System.Buffers.ArrayPool<T> 是 .NET 为了避免频繁创建和销毁大数组、减少 GC 压力而提供的一种数组租赁机制。

其默认实现为:System.Buffers.DefaultArrayPool<T>,核心结构如下:

  • 内部结构:

    • 通过 T[][] _buckets 管理多个“桶”,每个桶存储特定大小的数组。
    • 每个桶对应一种标准容量(例如 16, 32, 64, ..., 最大支持到 1024*1024 级别)。
    • 每个桶由一个 LockedStack<T[]>(或 ConcurrentBag)管理,支持线程安全的数组归还与租赁。
  • 关键逻辑:

    • Rent(int minimumLength)

      • 查找大于等于 minimumLength 的最近标准桶;若有可用数组直接返回,否则新建一个。
      • 若请求长度大于 MaximumBufferSize,直接分配新数组。
    • Return(T[] array, bool clearArray = false)

      • 根据数组长度判断归还到哪个桶;若桶已满,则该数组被抛弃(等待 GC)。
      • clearArray = true 会调用 Array.Clear() 清空内容,避免引用保留导致的内存泄漏。
  • 性能特性:

    • 大量减少 byte[], int[] 等大数组频繁分配导致的 LOH(大对象堆)GC 开销。
    • 使用线程本地栈优化热路径性能,避免锁竞争。
    • 分桶策略有效避免数组碎片和大小不均。
  • 使用建议:

    • 配合 Span<T>Memory<T> 使用更高效。
    • 网络通信中,推荐作为接收缓冲区、序列化缓存池等。

优势总结:复用大数组减少 GC,提升网络中数据处理性能,降低内存抖动。

Span / Memory 的底层设计结构

Span:

  • Span<T> 是一种轻量结构体(ref struct),用于表示托管内存中的一段连续区域(如数组、栈上内存、堆上内存的一部分)。

  • 特点:

    • 无堆分配、无 GC
    • 支持 Slice()CopyTo() 等高效操作
    • 不能存储在字段中或捕获到 lambda 中,因为它可能指向栈上内存
  • 底层结构:

    • 包含:ref T _pointerint _length
    • JIT 会将对 Span 的访问转成指针偏移 + 边界检查

Memory:

  • Memory<T>Span<T> 类似,但是可存储的引用类型

  • 特点:

    • 可作为字段、异步传递
    • 内部包含一个引用和偏移信息
    • 可通过 .Span 转换成 Span<T> 使用

应用场景:

  • 使用 ArrayPool + Span<byte> 构建零 GC 的序列化系统
  • 异步函数中使用 Memory<T> 传递缓存,避免数组拷贝

C# vs C/C++ 网络通信性能对比(基于 Xlua 项目)

场景说明

在 C# + Xlua 结构中,如果将 Socket 网络通信逻辑移到 C/C++ 层,有如下潜在性能提升:

提升点分析

项目 使用 C# 实现 使用 C/C++ 实现
GC 压力 高(byte[]频繁回收) 低(自行管理内存)
字节操作性能 中(需借助 Span, BinaryWriter 高(直接指针操作)
多线程 受限于托管线程模型 可使用原生多线程
跨 Lua 调用 Xlua 调用 C# 较频繁 可通过 P/Invoke 减少 Lua ↔︎ C# 跳转
数据结构复用 需使用 ArrayPool 或自建池 malloc/free + buffer pool 灵活控制
CPU 指令优化 不可控 可用 SIMD、zero-copy、batch send 等手段

场景适配建议

  • 通信极致高频(如 20ms 一帧、RTS 推送)推荐用 C/C++
  • 若瓶颈在 IO 线程与序列化,可尝试 C# 调用原生插件(如 FlatBuffers C API)
  • 若逻辑复杂不宜重构,仍可在 C# 中精细优化 buffer 分配与内存布局

附加建议:高并发优化策略

  • 使用异步 I/O (SocketAsyncEventArgs) 或 IOCP(Windows)提升吞吐

  • 网络协议压缩(Snappy / Zstd)降低传输成本, 但需要注意:加密后是高熵(数据重复度低)数据,压缩无意义,所有需要先压缩,再加密。否则压缩毫无意义,反而增加开销

  • 心跳机制定时 + RTT 检测,动态调整重传超时(RTO)

  • 使用双缓冲避免线程读写冲突

  • 将关键协议划分为:

    • 高优先级(心跳、同步帧)
    • 中优先级(状态同步)
    • 低优先级(日志、战报) 实现分通道异步发送(多队列)

参考