乌啦呀哈呀哈乌啦!

欢迎光临,这里是喵pass的个人博客,希望有能帮到你的地方

0%

延迟初始化

  • 采用按需加载/Lazy Initialization的思路,在运行时加载需要的资源,而不是在编译时加载所有的资源。
  • 仅在 UI 界面真正需要显示时,才调用 Instantiate 创建对象或从对象池中取出。

分帧加载 (Time-Slicing)

  • 对于包含大量子元素的列表(如背包系统),不要在同一帧初始化所有项。
  • 可以使用协程(Coroutine)每帧只初始化 2-3 个元素,避免单帧计算过载导致的掉帧。

动静分离

  • 将频繁更新的动态 UI 与不常变动的静态 UI 拆分到不同的 Canvas 中,因为每次UI更新都会触发 Canvas 的重建。
  • 这样当动态 UI 延迟加载并触发重建(Rebuild)时,不会影响到静态 Canvas 的性能。

对象池

  • 对象池可以提高对象创建和销毁的效率,因为对象池中的对象可以重复使用,从而避免了创建和销毁对象的开销。
  • 延迟初始化后,不使用的 UI 不要直接 Destroy,而是放入对象池中。下次需要时,直接从池中取出并重新初始化,这比反复 Instantiate 性能更高。

预加载

  • 预加载常用的资源,比如场景中的静态资源、UI 资源、音效资源等等。
  • 在场景加载阶段(Loading 界面)预先实例化核心 UI 并隐藏(SetActive(false)),这属于将提前初始化,而非延迟初始化,适合高频使用的核心界面。

避免 Layout Group 重建

  • 瞬间加载大量元素到含有 VerticalLayoutGroup 的父物体时,会导致剧烈的 Layout 重建。
  • 可以先禁用 Layout 组件,等所有元素加载完毕后,使用 LayoutRebuilder.ForceRebuildLayoutImmediate 一次性刷新。

CPU-GPU 交互流程

关注点:CPU 如何告诉 GPU “要画什么”?这决定了代码架构和内存管理方式。

立刻渲染模式(Immediate Mode)

每一帧都在 Update 或 OnGUI 里写 DrawButton(),简单直观,但如果物体多,CPU 喊口令会累死。

工作原理

  • 核心在于“每帧重新绘制”,无需保存图形对象的复杂状态
  • 它具有简单直接、易于动态更新的优点,但因缺乏对象层级的记录,CPU负载通常较高
  • 每次调用绘制命令(如 glDrawArrays、DrawPrimitive),GPU 就立即执行渲染,结果直接写入帧缓冲区。

执行流程

1
绘制物体A → 立即渲染A → 绘制物体B → 立即渲染B → 绘制物体C → 立即渲染C

特点

  1. 无状态管理 - 每次绘制独立,不保存场景信息
  2. 实时响应 - 调用即渲染,适合简单场景
  3. 内存占用小 - 不需要额外的场景数据结构

保留模式(Retained Mode)

创建一个 GameObject 或 DOM 节点,之后除非它变了,否则你不用管它,内存占用高(要存菜单),但 CPU 负担轻,库会帮你自动优化。

工作原理

  • 与立刻模式相对的渲染模式,其核心逻辑是“由图形库管理场景”
  • 在这种模式下,应用程序通过构建一个场景模型(如对象树或场景图)来告诉库“要画什么”,然后库会负责具体的绘制细节、状态维护和按需重绘
    • 例如在Unity中,场景模型就是对象树,对象树中的每个对象都包含着对象模型(如Mesh)、材质(如Shader)、变换矩阵等信息

执行流程

1
构建场景图 → 提交给引擎 → 引擎优化(剔除、排序、批处理)→ 统一渲染

特点

  1. 层次化管理 - 通过场景图管理物体关系
  2. 自动优化 - 视锥剔除、遮挡剔除、状态排序
  3. 延迟执行 - 可以批量处理多个绘制调用,适合大型场景的优化

渲染路径

GPU 收到命令后,“具体怎么算光影”?这决定了显卡的计算逻辑和画面表现。

前向渲染(Forward Rendering)

Unity URP 默认使用的是前向渲染
拿一个零件(物体),直接打磨、上色、抛光(算所有光照),彻底做完再拿下一个零件。
但如果零件受 10 盏灯照,每个零件都要算 10 遍,灯多了会慢。

工作原理

  • 图形学中最传统、最基础的渲染路径。
  • 它的核心逻辑是“遍历物体,直接着色”:针对场景中的每一个几何物体,依次进行顶点变换、光栅化,并直接在像素着色器中计算所有相关的光照效果,最后输出到屏幕缓冲区。

单 Pass 方式

  • 一次绘制计算所有光照
  • 光源数量有限(通常 4-8 个)
  • 性能随光源线性下降

多 Pass 方式

1
2
3
4
Pass 1: 渲染物体 + 主光源
Pass 2: 添加光源1贡献(混合)
Pass 3: 添加光源2贡献(混合)
...

特点

  1. 直接计算 - 几何和光照同时处理,适合光源较少的场景
  2. 透明物体友好 - 天然支持透明度排序
  3. MSAA 原生支持 - 多重采样抗锯齿直接可用

延迟渲染(Deferred Rendering / Deferred Shading)

Unity HDRP 默认使用延迟渲染
第一批人先给零件分类(记录位置、颜色到 G-Buffer)。第二批人最后统一刷漆(对着 G-Buffer 统一算光照)。
不怕灯多,适合 PC 大作;但费内存(G-Buffer 很大),且处理不了半透明物体。

工作原理

它的核心理念是“先记录信息,最后统一算光照”,流程分为以下两个阶段:

  1. 几何处理阶段 (Geometry Pass):将场景中的每个物体进行顶点变换、光栅化,并记录所有相关的几何信息(如位置、法线、漫反射颜色、粗糙度等),将物体的几何属性存储在一组被称为 G-Buffer (Geometry Buffer) 的高带宽纹理中
  2. 光照处理阶段 (Lighting Pass):将G-Buffer里的记录的光照信息进行计算,针对屏幕上的每个像素统一计算最终的光照效果并输出到屏幕缓冲区。

几何 Pass

1
2
3
对于每个物体:
只写入几何信息到 G-Buffer
不计算任何光照

光照 Pass

1
2
3
4
对于每个像素:
从 G-Buffer 读取几何信息
计算所有光照贡献
输出最终颜色

G-Buffer 结构

1
2
3
4
RT0: RGB = Albedo颜色       A = Metallic
RT1: RGB = 法线 A = Roughness
RT2: RGB = 自发光 A = AO
RT3: RGB = 世界坐标/深度

每个渲染目标(RT)有 4 个通道:R、G、B、A

RT0: 基础材质属性

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────┐
│ R G B A │
│ Albedo.r Albedo.g Albedo.b Metallic │
└─────────────────────────────────────────────┘

RGB (Albedo): 物体的基础颜色(漫反射颜色)
- 例如:红色物体 → (1.0, 0.0, 0.0)
- 从纹理采样得到

A (Metallic): 金属度 (0.0 ~ 1.0)
- 0.0 = 非金属(电介质):塑料、木材、布料
- 1.0 = 金属:金、银、铜
- 影响高光和反射计算方式

RT1: 表面细节属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────────────────────────────────────────┐
│ R G B A │
│ Normal.r Normal.g Normal.b Roughness │
└─────────────────────────────────────────────┘

RGB (Normal): 世界空间法线向量
- 表示表面朝向
- 单位向量,范围 [-1, 1] 映射到 [0, 1]
- 例如:(0, 1, 0) = 表面朝上
- 用于光照计算(漫反射、高光)

A (Roughness): 粗糙度 (0.0 ~ 1.0)
- 0.0 = 光滑表面:镜子、水面(清晰反射)
- 1.0 = 粗糙表面:混凝土、沙石(模糊高光)
- 控制高光的锐利程度

RT2: 发光与环境光遮蔽

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────────────────────────┐
│ R G B A │
│ Emissive.r Emissive.g Emissive.b AO │
└─────────────────────────────────────────────┘

RGB (Emissive): 自发光颜色
- 物体自身发出的光
- 例如:霓虹灯、LED、发光材质
- 不受光照影响,直接叠加到最终颜色

A (AO - Ambient Occlusion): 环境光遮蔽 (0.0 ~ 1.0)
- 0.0 = 完全遮蔽(角落、缝隙)
- 1.0 = 无遮蔽(暴露表面)
- 模拟缝隙中的阴影,增加真实感

RT3: 深度/位置信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────────────────────────┐
│ R G B A │
│ Position.r Position.g Position.b Depth │
│ 或仅使用 Depth 作为单通道 │
└─────────────────────────────────────────────┘

RGB (World Position): 世界坐标
- 像素在世界空间中的 XYZ 位置
- 或者从深度值重建位置(节省显存)

A (Depth): 深度值
- 像素到相机的距离
- 用于重建世界坐标
- 用于后处理效果(雾、景深)

特点

  1. 解耦几何与光照 - 几何阶段只处理位置、法线等
  2. 光照与物体数无关 - 光照计算只针对可见像素
  3. 需要大量显存 - G-Buffer 占用显著


Credits

https://blog.csdn.net/weixin_70073176/article/details/141165074

Unity 的粒子特效主要基于内置的 Particle System 组件。它适合创建火焰、烟雾、爆炸、雪花、魔法光效等视觉效果。
粒子系统通过一组不断生成、模拟、渲染的“粒子”来表现复杂的视觉效果,而不需要为每一个物体写单独的网格。

粒子系统的核心概念

  • Emitter(发射器):粒子从何处发射,通常由发射形状(Shape)决定。
  • Lifetime(寿命):每个粒子存在的时间。
  • Velocity(速度):粒子运动方向与速度。
  • Size / Color over Lifetime:粒子在生命周期中大小和颜色的变化。
  • Emission Rate(发射率):每秒生成粒子数量。
  • Renderer:粒子最终如何绘制,常见为 Billboard、Mesh、Stretch Billboard。

Unity 粒子系统常用模块

  • Emission:控制发射速率、爆发次数。
  • Shape:控制粒子发射区域,如 Sphere、Cone、Box 等。
  • Velocity over Lifetime:设置粒子在生命周期内的速度变化。
  • Color over Lifetime:设置颜色渐变。
  • Size over Lifetime:控制大小随时间变化。
  • Rotation over Lifetime:控制粒子旋转。
  • Noise:添加噪声让粒子运动更自然。
  • Collision:让粒子与世界碰撞。

混合代码与粒子系统使用方式

粒子系统通常由编辑器配置,但也可以通过脚本动态控制。下面是一个常见的代码用例:创建、配置并触发粒子效果。

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
34
35
36
37
38
39
40
41
using UnityEngine;

public class ParticleEffectSpawner : MonoBehaviour
{
// `ParticleSystem` 组件可直接挂在预制体上,脚本通过 `Instantiate` 生成实例。
public ParticleSystem particlePrefab;

void Start()
{
if (particlePrefab == null)
{
Debug.LogWarning("请在 Inspector 中指定 particlePrefab");
return;
}
}

public void SpawnEffect(Vector3 position)
{
var instance = Instantiate(particlePrefab, position, Quaternion.identity);

// `main`、`emission`、`shape` 等模块都可以在运行时通过脚本访问。
var main = instance.main;
main.startLifetime = 1.5f;
main.startSpeed = 4f;
main.startSize = 0.8f;
main.startColor = Color.cyan;

var emission = instance.emission;
emission.rateOverTime = 100f;

var shape = instance.shape;
shape.shapeType = ParticleSystemShapeType.Cone;
shape.angle = 20f;
shape.radius = 0.5f;

instance.Play();

// `Destroy` 用于清理粒子对象,避免特效结束后残留内存。
Destroy(instance.gameObject, main.startLifetime.constant + 0.5f);
}
}

运行时参数控制示例

通过脚本修改粒子参数,可以实现不同风格的特效,例如爆炸效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void Explode(Vector3 position)
{
var instance = Instantiate(particlePrefab, position, Quaternion.identity);
var main = instance.main;
main.startLifetime = 0.8f;
main.startSpeed = 10f;
main.startSize = 1.5f;
main.startColor = new Color(1f, 0.5f, 0.1f);

var emission = instance.emission;
emission.rateOverTime = 0f;
emission.SetBurst(0, new ParticleSystem.Burst(0f, 50, 80));

var shape = instance.shape;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 0.3f;

instance.Play();
Destroy(instance.gameObject, main.startLifetime.constant + 0.5f);
}

与 Shader 的区别

Unity 粒子特效通常由 Particle System 来驱动整体行为,而 Shader 决定粒子的最终视觉表现。
两者搭配使用,既可以快速构建特效逻辑,也能在渲染上实现更加细腻的视觉结果。

粒子系统和 Shader 的职责不同

  • Particle System:负责粒子的生成、生命周期、位置、速度、发射、碰撞、颜色和大小变化等整体行为。
  • Shader:负责单个粒子的最终渲染效果,如纹理、颜色混合、透明度、光照、动画遮罩等。

粒子系统通常使用内置或自定义材质,材质中可以包含 Shader。也就是说,粒子系统负责“做出粒子”,Shader 负责“怎么画粒子”。

使用场景差异

  • 粒子系统适合快速搭建常见特效、控制复杂行为、在编辑器中调整参数。
  • Shader 适合实现特效的渲染细节,如渐隐、闪烁、扫描、光晕与特殊混合模式。

例如:你可以让 Particle System 负责生成烟雾粒子,而使用 UnlitAdditive Shader 实现在透明面上绘制柔和、发光的烟雾纹理。

性能与复杂度

  • Particle System 自身封装了大量行为,适合面向特效设计师快速迭代。
  • Shader 则在 GPU 上运行、效率更高,但需要编写渲染代码,对每个像素/顶点进行控制。

如果希望增强粒子表现,可以结合 Shader Graph 或自定义 Shader,使粒子面具备“流动光晕”、“波动边缘”、“复杂透明度曲线”等效果。

结合 Shader 的粒子效果

一个常见做法是:

  1. Particle System 中设置材质为 Particle/Additive 或自定义粒子材质。
  2. 使用纹理图集、Color over LifetimeSize over Lifetime 调整粒子表现。
  3. 在 Shader 中实现附加效果,例如渐隐、扭曲、噪声贴图 UV 变换。

自定义 Shader 示例

下面是一个简单的粒子自定义 Shader,它对粒子透明度和 UV 进行了处理:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
Shader "Custom/ParticleSoft"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Color ("Tint", Color) = (1,1,1,1)
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" }
Blend SrcAlpha One
Cull Off
Lighting Off
ZWrite Off

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"

sampler2D _MainTex;
float4 _Color;

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};

struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};

v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
o.color = v.color * _Color;
return o;
}

fixed4 frag(v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
tex.rgb *= i.color.rgb;
tex.a *= i.color.a;
return tex;
}
ENDCG
}
}
}

这个 Shader 只负责将粒子贴图按颜色和透明度渲染出来,而粒子系统仍然负责产生位置、速度和生命周期。

实战建议

  • 先用编辑器调整粒子系统参数,确认效果后再用脚本控制动画和触发时机。
  • 将复杂行为拆成多个粒子系统,如火焰、烟雾、火花分别使用不同系统组合。
  • 如果粒子数量大或效果复杂,可考虑粒子 Shader 优化,例如使用 Soft ParticlesGPU InstancingShader Graph
  • 当粒子需要与场景光照、阴影或深度融合时,配合自定义 Shader 和深度测试设置提高表现力。

Bleeding

纹理渗色/纹理出血,这是一种图像处理或显示技术问题,指一种颜色“溢出”到相邻像素的现象,导致颜色边界不清晰、产生伪影或颜色混杂。

Texture bleeding is when pixels from outside a sprite’s intended boundary leak into the visible edge of the sprite.

Here’s why it happens. When the GPU samples a texture, it rarely samples at an exact pixel center.
Bilinear filtering works by taking the 4 nearest pixels and blending them together based on how close the sample point is to each one.
At the very edge of a sprite, one of those 4 neighbors is outside the sprite — it could be a transparent black pixel, a pixel from another sprite in an atlas, or garbage data. That outside color gets mixed into your edge pixel, producing a fringe(边缘).

For example:

1
2
3
[ edge pixel: orange ][ outside: transparent black (0,0,0,0) ]
^
bilinear samples here → blends orange + black = dark muddy fringe

This is exactly what you saw — the logo’s colored edge pixels were bleeding into the transparent area around it, and because the shader was Opaque, those blended semi-transparent edge pixels were written as solid color instead of being discarded.

There are three common sources of bleeding:

  • Wrap Mode: Repeat — the GPU wraps around and samples the opposite edge of the texture
  • Atlas packing with no padding — neighboring sprites bleed into each other
  • Opaque surface type with a transparent sprite — alpha is ignored so blended edge pixels render solid

Opaque vs Transparent

The GPU renders geometry in two stages: rasterization and blending.
When Surface Type is Opaque, the blending stage is skipped entirely — the GPU writes every pixel the fragment shader touches directly to the framebuffer, alpha value be damned.
Your shader was correctly computing alpha from the texture, but the pipeline threw it away before it ever reached the screen.

The “noise” you saw was bilinear filtering. At the sprite’s edges, the sampler blends the last row/column of real pixels with whatever is just outside the sprite boundary (either transparent black rgba(0,0,0,0) or neighboring texture data).
With opaque mode, those semi-transparent edge pixels get written as fully solid, so you see that fringe of mixed color.

Use Opaque when:
The object has no see-through areas at all (terrain, walls, characters without cutouts)
Performance matters — opaque objects are rendered in a single front-to-back pass and benefit from early-z culling, which skips fragment shading on occluded pixels entirely

Use Transparent when:
The object has partial or full transparency (UI sprites, glass, particles, logos with alpha)
You need the blending stage to mix the fragment color with what’s already in the framebuffer
The cost of transparent is real — transparent objects can’t use early-z, must be sorted back-to-front, and can cause overdraw issues. So don’t default everything to transparent.

Blend Mode (within Transparent)

1
2
3
4
5
6
| Mode | Formula | Use case |
| --- | --- | --- |
| Alpha | src.rgb * src.a + dst.rgb * (1 - src.a) | Standard transparency, sprites, UI
| Additive | src.rgb * src.a + dst.rgb | Glows, fire, particles that should brighten
| Multiply | src.rgb * dst.rgb | Darkening effects, shadows, tinted overlays
| Premultiply | src.rgb + dst.rgb * (1 - src.a) | Textures with premultiplied alpha baked in

The Z-Buffer

The GPU maintains a depth buffer (z-buffer) alongside the color buffer. Every pixel stores the depth of the closest thing rendered to it so far.
When a new fragment arrives, the GPU compares its depth against what’s already in the buffer — if it’s further away, it gets discarded.

Front-to-Back Pass

For opaque objects, Unity sorts them front-to-back before drawing. So the closest objects get drawn first.

1
2
3
Camera → [Chair] → [Table] → [Wall behind]
drawn drawn drawn
first second third

This matters because of early-z.

Early-Z Culling

Once the chair is drawn and its depth is written to the z-buffer, when the wall fragment arrives at the same pixel, the GPU checks:

1
wall depth (5.0) > z-buffer value (1.2) → discard, skip fragment shader

It never runs the fragment shader for the wall pixel at all. This is early-z culling — rejecting fragments before the expensive shading work happens.
The savings are significant. Fragment shaders do lighting, texture sampling, math — all skipped for occluded pixels.

Why transparent objects break this

Transparent objects need to blend with what’s behind them, so they must be drawn last, back-to-front:

1
Camera → [Wall] → [Table] → [Glass in front]

The glass needs the wall already in the framebuffer to blend against it. This means:

  • You can’t sort front-to-back
  • Early-z culling doesn’t apply
  • Every transparent fragment runs the full fragment shader regardless of what’s in front of it

That’s the real performance cost of transparency — not the blending itself, but losing early-z and the overdraw that comes with back-to-front rendering.

Trail Renderer 是 Unity 中的一个组件,用于在移动对象后面创建拖尾效果,常用于粒子轨迹、运动模糊、武器轨迹等视觉效果。

主要功能

  • 轨迹生成: 基于对象位置历史记录创建连续的轨迹线条
  • 材质支持: 支持自定义纹理、颜色渐变和透明度
  • 宽度控制: 可设置轨迹的起始和结束宽度,支持曲线调整
  • 生命周期: 可控制轨迹的生存时间和淡出效果
  • 性能优化: 自动批处理,适合移动平台

自动批处理

Unity 的渲染系统会自动检测并合并使用相同材质的 Trail Renderer 组件,将多个轨迹渲染合并为单个 Draw Call。

批处理条件:

  • 相同的 Material(材质)
  • 相同的 Shader 变体
  • 相似的渲染状态(深度测试、混合模式等)
  • 轨迹顶点总数不超过批处理限制
触发Trail Renderer 自动批处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using UnityEngine;

public class TrailBatchOptimizer : MonoBehaviour
{
// 为相似轨迹使用同一个 Material 实例
[SerializeField] private Material sharedTrailMaterial;
[SerializeField] private TrailRenderer[] trailRenderers;

void Start()
{
// 确保所有轨迹使用相同材质以启用批处理
foreach (var trail in trailRenderers)
{
trail.material = sharedTrailMaterial;

// 优化设置
trail.minVertexDistance = 0.1f; // 减少顶点数量避免过度细分
trail.time = 1.0f; // 控制轨迹长度防止轨迹过长
trail.widthMultiplier = 1.0f; // 统一宽度比例
}
}
}

使用方法

GameObject → Add Component → Effects → Trail Renderer

  • Material: 设置轨迹材质
  • Width: 轨迹宽度曲线
  • Color: 颜色渐变
  • Lifetime: 轨迹持续时间
  • Min Vertex Distance: 最小顶点距离(影响轨迹平滑度)

代码示例

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
34
35
36
37
38
39
40
41
42
43
using UnityEngine;

public class TrailController : MonoBehaviour
{
private TrailRenderer trailRenderer;

void Start()
{
trailRenderer = GetComponent<TrailRenderer>();

// 设置轨迹材质
trailRenderer.material = Resources.Load<Material>("TrailMaterial");

// 设置宽度(起始0.1,结束0.5)
trailRenderer.startWidth = 0.1f;
trailRenderer.endWidth = 0.5f;

// 设置生命周期
trailRenderer.time = 2.0f;

// 设置颜色渐变
Gradient gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] {
new GradientColorKey(Color.red, 0.0f),
new GradientColorKey(Color.yellow, 0.5f),
new GradientColorKey(Color.blue, 1.0f)
},
new GradientAlphaKey[] {
new GradientAlphaKey(1.0f, 0.0f),
new GradientAlphaKey(1.0f, 0.8f),
new GradientAlphaKey(0.0f, 1.0f)
}
);
trailRenderer.colorGradient = gradient;
}

void Update()
{
// 移动对象以生成轨迹
transform.Translate(Vector3.forward * Time.deltaTime);
}
}

注意事项:

  • Trail Renderer 需要对象持续移动才会产生轨迹
  • 对于高速移动对象,可调整 Min Vertex Distance 避免轨迹断裂
  • 轨迹顶点数量会影响性能,建议合理设置 Lifetime
  • 支持 Texture Mode 用于更复杂的纹理映射效果

Trail Renderer 和粒子系统

Trail Renderer专门用于创建移动对象的连续拖尾轨迹,基于对象位置历史记录生成线条。
而粒子系统由发射参数控制,用于发射和管理大量离散粒子(如烟雾、火花、雨滴),每个粒子独立运动。

常见结合

  • 粒子轨迹: 为粒子系统中的粒子添加 Trail Renderer,创建带拖尾的粒子效果(如流星、魔法弹道)。
  • 增强效果: Trail Renderer 提供连续轨迹,粒子系统提供散射效果。
为游戏对象同时适配ParticleSystem 和 TrailRenderer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using UnityEngine;

public class ParticleTrailExample : MonoBehaviour
{
[SerializeField] private ParticleSystem particleSystem;
[SerializeField] private TrailRenderer trailRenderer;

void Start()
{
// 为粒子系统的主发射器添加轨迹
var main = particleSystem.main;
main.startLifetime = 2.0f; // 粒子生命周期

// 配置轨迹
trailRenderer.time = 1.0f;
trailRenderer.startWidth = 0.1f;
trailRenderer.endWidth = 0.0f;

// 轨迹跟随粒子发射器移动
trailRenderer.transform.SetParent(particleSystem.transform);
}
}

DrawCall 是 CPU 向 GPU 发送的一次渲染指令,包含了 GPU 绘制所需的全部信息:几何数据、纹理、Shader、Buffer 等。
每次调用 DrawMesh、DrawRenderer 等,就是一个 Draw Call。GPU 收到命令后开始渲染对应的几何体。

1
2
3
4
5
6
7
CPU 准备渲染数据

设置渲染状态(材质、Shader、纹理绑定)← SetPass Call

提交 DrawCall(告诉 GPU 画什么、怎么画)

GPU 执行光栅化、着色、输出像素

关键点:DrawCall 本身消耗资源,但准备阶段(SetPass Call)通常比 DrawCall 本身更耗性能。
开启批处理后,Unity 可以用一次 SetPass Call 复用同一材质状态,然后提交多个 DrawCall。所以降低 SetPass Call 往往比降低 DrawCall 更重要。

DrawCall(绘制调用)

CPU准备数据并通过调用图形API接口命令GPU对指定物体进行渲染一次的操作称为一次DrawCall。
当收到一个DrawCall时,GPU会按照指令,根据渲染状态和输入的顶点信息,指定一个网格(Mesh)进行渲染,绘制材质。
GPU 渲染时,同一批次的对象必须使用相同的材质和纹理。一旦纹理不同,就必须切换渲染状态,触发新的 DrawCall。

1
2
3
Image A(纹理1)→ DrawCall 1
Image B(纹理2)→ DrawCall 2(纹理不同,无法合批)
Image C(纹理1)→ 可以与 A 合批,不增加 DrawCall

为了CPU和GPU可以进行并行工作,需要一个命令缓冲区,由CPU向其中添加命令,然后由GPU从中读取命令,这样就实现了通过CPU准备数据,通知GPU进行渲染。

SetPass Call

在 Draw Call 之前,如果需要切换渲染状态(shader、材质属性、纹理等),CPU 就要先做一次 SetPass Call 来通知 GPU 切换 Pass。
SetPass Call 才是真正的性能瓶颈,因为它涉及 shader 编译、状态切换等开销。

例如场景里有 5 个物体:

1
2
3
4
5
物体A - 材质1 (Shader: Standard, 纹理: tex_a)
物体B - 材质1 (同上)
物体C - 材质1 (同上)
物体D - 材质2 (Shader: Standard, 纹理: tex_b)
物体E - 材质3 (Shader: Unlit, 纹理: tex_c)

渲染顺序:

1
2
3
4
5
6
7
8
SetPass Call → 切换到材质1
Draw Call → 绘制物体A
Draw Call → 绘制物体B
Draw Call → 绘制物体C
SetPass Call → 切换到材质2
Draw Call → 绘制物体D
SetPass Call → 切换到材质3
Draw Call → 绘制物体E

结果:5 个 Draw Call,3 个 SetPass Call。

Pass 是什么

一个 Pass 就是 shader 里的一次完整渲染流程,包含一套完整的顶点着色器 + 片元着色器,以及对应的渲染状态(混合模式、深度测试、剔除等)。
一个材质可以有多个 Pass,每个 Pass 对同一个物体渲染一遍。

  • 具体例子:描边效果
    描边通常需要两个 Pass:第一个 Pass 画描边(把模型沿法线方向放大,只渲染背面),第二个 Pass 画正常颜色。
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
Shader "Custom/Outline"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_OutlineColor ("Outline Color", Color) = (0,0,0,1)
_OutlineWidth ("Outline Width", Float) = 0.02
}

SubShader
{
// ---- Pass 0:描边 ----
Pass
{
Name "OUTLINE"
Cull Front // 只渲染背面,正面被裁掉

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

float _OutlineWidth;
float4 _OutlineColor;

struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; };
struct v2f { float4 pos : SV_POSITION; };

v2f vert(appdata v)
{
v2f o;
// 沿法线方向把顶点往外推
float3 expanded = v.vertex.xyz + v.normal * _OutlineWidth;
o.pos = UnityObjectToClipPos(float4(expanded, 1));
return o;
}

fixed4 frag(v2f i) : SV_Target
{
return _OutlineColor; // 纯色描边
}
ENDCG
}

// ---- Pass 1:正常渲染 ----
Pass
{
Name "BASE"
Cull Back // 只渲染正面

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

float4 _Color;

struct appdata { float4 vertex : POSITION; };
struct v2f { float4 pos : SV_POSITION; };

v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
return o;
}

fixed4 frag(v2f i) : SV_Target
{
return _Color;
}
ENDCG
}
}
}

渲染这一个物体时,GPU 会走两遍:

1
2
3
4
5
SetPass Call → 激活 Pass 0 (OUTLINE),设置 Cull Front 状态
Draw Call → 用 Pass 0 的 shader 绘制物体(画出描边)

SetPass Call → 激活 Pass 1 (BASE),设置 Cull Back 状态
Draw Call → 用 Pass 1 的 shader 绘制物体(画出本体)

所以 1 个物体、1 个材质,产生了 2 个 SetPass Call + 2 个 Draw Call。

Batching

静态合批: 将相同材质并且始终不动的的Mesh合并成为一个大Mesh,然后由CPU合并为一个批次发送给GPU处理,从而减少DrawCall带来的消耗。
动态合批: 相同材质并且会动的物体Mesh合并成为一个大Mesh
SRP Batcher: 将多个网格合并成单个批次进行渲染,从而提高性能。与其他合批不同,SRP Batcher将未改变属性的Mesh缓存起来,从而减少消耗
GPU Instancing: 使用一个DrawCall渲染多个相同材质的网格对象(如场景中大量重复的物体如树木和草地等),从而减少CPU和GPU的开销

Static Batching

针对静态物体(勾选 Static)。在构建时,Unity 把所有使用相同材质的静态 mesh 合并成一个大 mesh,存到内存里。运行时一次 Draw Call 画完。

1
2
3
场景里100棵树(同材质,Static):
构建时合并 → 1个大mesh
运行时:SetPass × 1,Draw Call × 1

代价是内存,合并后的大 mesh 常驻内存,物体也不能移动。

Dynamic Batching

与静态合批不同,动态合批是的物体是可以运动的,针对动态小物体。每帧 CPU 实时把符合条件的 mesh 合并,然后一次提交。
条件很苛刻:顶点数 < 900,不能使用lightmap,不能使用多通道的shader,不能是缩放比不同的物体。
Unity 的 Dynamic Batching(动态批处理)是自动触发的。只要启用了该功能且物体满足特定条件(如使用相同材质、顶点数较少),Unity 引擎就会在运行时自动合并 Draw Call,无需手动干预。

1
2
每帧:
CPU 检查哪些动态物体同材质 → 临时合并 mesh → 一次 Draw Call

代价是 CPU 每帧都要做合并,物体多了反而更慢,现在基本被 GPU Instancing 取代。

SRP Batcher

Batches draw calls by grouping objects that use the same shader variant, reducing SetPass calls
Shader properties stored in GPU buffers instead of individual material constants

使用条件:

  • 不支持内置渲染管线,支持通用(URP)/高清(HDRP)/自定义(SRP)渲染管线
  • 不使用 MaterialPropertyBlocks
  • Materials must use SRP-compatible shaders

在标准的渲染流程下,CPU需要收集所有场景物体的参数,场景中的材质越多CPU提交给GPU的数据就越多。
而在SRP中流程下GPU拥有数据管理的 “生命权” ,管理大量不同材质但Shader变动较小的的内容。
让数据在GPU中持久存在,从而减少消耗(SetPass Call)。

SRP Batcher vs. Dynamic Batching

Both can be used together in SRP, but SRP Batcher takes precedence when enabled. For optimal performance in modern Unity projects, prefer SRP Batcher with proper shader setup.

Both Dynamic Batching and SRP Batcher are triggered automatically in Unity when their conditions are met:

  • Unity automatically detects and performs Dynamic Batching on compatible objects at runtime
  • SRP batcher automatically batches during render loop when objects use compatible shader variants enabled in the render pipeline settings (Window → Rendering → Render Pipeline → Universal Render Pipeline → General → SRP Batcher)

GPU Instancing

针对大量相同 mesh + 相同材质的物体(但允许不同的属性,比如颜色、位置)。CPU 只提交一次 mesh 数据,附带一个”实例属性数组”,GPU 自己循环绘制 N 次。
在 Unity 中,需要在材质属性中勾选 “Enable GPU Instancing”。

1
2
3
4
// shader 里声明每个实例可以有不同颜色
UNITY_INSTANCING_BUFFER_START(Props)
UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
UNITY_INSTANCING_BUFFER_END(Props)

使用条件:

  • 材质需要支持GPU Instancing,例如默认标准材质就有
  • Tranform信息需要有所不同,(完全重合了渲染出来也没有意义)
  • 未使用SRP Batcher,如有会优先使用SRP Batcher。(在URP渲染管线中是默认开启的)
  • 粒子对像不能合批
  • 使用MaterialPropertyBlocks的游戏不能合批
  • Shader必须是使用compatible的

此时的CPU依然只提交1次Drawcall

1
2
3
4
100棵树(同mesh,同材质,不同位置/颜色):
CPU:提交1次mesh + 100个transform数组
GPU:自己 instance 100次
结果:SetPass × 1,Draw Call × 1

Sprite Atlas 图集

把多张小纹理合并成一张大纹理,GPU 渲染时所有引用该图集的 Sprite 共享同一张纹理,满足合批条件,从而将多个 DrawCall 合并为一个。

1
2
3
4
5
6
7
8
9
未使用图集:
Sprite A(texture_a.png)→ DrawCall 1
Sprite B(texture_b.png)→ DrawCall 2
Sprite C(texture_c.png)→ DrawCall 3

使用图集后:
Sprite A ─┐
Sprite B ─┼─ atlas.png → DrawCall 1(合批)
Sprite C ─┘

除了减少 DrawCall,图集还有两个底层优化:

  • GPU 处理 2 的幂次方尺寸的纹理效率更高,图集会自动将小图打包成符合规范的大图
  • 多张小图的磁盘占用通常大于一张合并后的大图(压缩算法在大块连续数据上效率更高)

Unity中的图集有2种类型

  • Master(主图集):标准图集,包含实际资源
  • Variant(变体图集):引用主图集内容,通过 Scale 缩放生成不同分辨率版本,适合高清/低清资源切换场景

代码用例

运行时加载图集(Resources)

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
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.U2D;

public class AtlasLoader : MonoBehaviour
{
[SerializeField] private Image[] _images;

void Start()
{
// 从 Resources 文件夹加载图集
SpriteAtlas atlas = Resources.Load<SpriteAtlas>("UI/MainAtlas");
if (atlas == null)
{
Debug.LogError("图集加载失败,检查路径是否正确");
return;
}

// GetSprite 传入的是打包时的文件名(不含扩展名)
// Single 模式:直接传文件名,如 "icon_sword"
// Multiple 模式(Sprite Sheet):传 "文件名_索引",如 "icon_sword_0"
_images[0].sprite = atlas.GetSprite("icon_sword");
_images[1].sprite = atlas.GetSprite("icon_shield");
_images[2].sprite = atlas.GetSprite("icon_potion");
}
}

从 AssetBundle 加载图集

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
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.U2D;

public class AtlasFromAB : MonoBehaviour
{
[SerializeField] private Image _targetImage;

void Start()
{
string bundlePath = Application.streamingAssetsPath + "/AssetBundles/ui_atlas";
AssetBundle bundle = AssetBundle.LoadFromFile(bundlePath);

if (bundle == null)
{
Debug.LogError("AB 包加载失败");
return;
}

SpriteAtlas atlas = bundle.LoadAsset<SpriteAtlas>("MainAtlas");
_targetImage.sprite = atlas.GetSprite("icon_sword");

// 图集资源已提取,可以卸载包体(false = 保留已加载资源)
bundle.Unload(false);
}
}

后期绑定(Late Binding)

当图集未勾选 Include in Build 时,必须手动监听 atlasRequested 事件,否则引用该图集的 Sprite 会显示为空白。

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
34
35
36
37
38
39
40
using UnityEngine;
using UnityEngine.U2D;
using System;

// 挂载到场景中持久存在的对象上(如 GameManager)
public class AtlasLateBinding : MonoBehaviour
{
void OnEnable()
{
// 注册图集请求事件
SpriteAtlasManager.atlasRequested += OnAtlasRequested;
// 可选:图集注册完成后的回调
SpriteAtlasManager.atlasRegistered += OnAtlasRegistered;
}

void OnDisable()
{
SpriteAtlasManager.atlasRequested -= OnAtlasRequested;
SpriteAtlasManager.atlasRegistered -= OnAtlasRegistered;
}

// Unity 找不到图集时触发,atlasName 是图集名称
private void OnAtlasRequested(string atlasName, Action<SpriteAtlas> callback)
{
Debug.Log($"请求图集: {atlasName}");

// 从 Resources 加载(也可以换成 AB 包加载)
SpriteAtlas atlas = Resources.Load<SpriteAtlas>(atlasName);

if (atlas != null)
callback(atlas); // 把图集交还给 Unity
else
Debug.LogError($"找不到图集: {atlasName}");
}

private void OnAtlasRegistered(SpriteAtlas atlas)
{
Debug.Log($"图集已注册: {atlas.name}");
}
}

Editor 工具:自动打包图集

这个工具会遍历指定目录,按文件夹自动创建并更新图集,适合团队统一管理图集资源。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// 必须放在 Editor 文件夹下
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEditor.U2D;
using UnityEngine;
using UnityEngine.U2D;
using Object = UnityEngine.Object;

public class SpriteAtlasBuilder
{
// 需要打包的图片根目录(每个子文件夹对应一个图集)
private static readonly string SourceRoot = Application.dataPath + "/Art/Sprites/";
// 图集输出目录
private static readonly string OutputPath = "Assets/Art/Atlases/";

[MenuItem("Tools/Build Sprite Atlases")]
public static void BuildAll()
{
if (!Directory.Exists(SourceRoot))
{
Debug.LogError($"源目录不存在: {SourceRoot}");
return;
}

Directory.CreateDirectory(OutputPath.Replace("Assets/", Application.dataPath + "/").Replace("Assets", ""));

var dirs = new DirectoryInfo(SourceRoot).GetDirectories();
for (int i = 0; i < dirs.Length; i++)
{
var dir = dirs[i];
EditorUtility.DisplayProgressBar("打包图集", $"处理: {dir.Name}", (float)i / dirs.Length);

string atlasPath = OutputPath + dir.Name + ".spriteatlas";
SpriteAtlas atlas = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(atlasPath)
?? CreateAtlas(atlasPath);

UpdateAtlasContent(atlas, SourceRoot + dir.Name);
}

EditorUtility.ClearProgressBar();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("图集打包完成");
}

private static SpriteAtlas CreateAtlas(string savePath)
{
var atlas = new SpriteAtlas();

// 基础打包设置
atlas.SetPackingSettings(new SpriteAtlasPackingSettings
{
blockOffset = 1,
enableRotation = false, // 禁止旋转,避免 UI 显示异常
enableTightPacking = false, // 禁止紧密排列,避免图片边缘互相干扰
padding = 4,
});

// 纹理设置
atlas.SetTextureSettings(new SpriteAtlasTextureSettings
{
readable = false,
generateMipMaps = false,
sRGB = true,
filterMode = FilterMode.Bilinear,
});

// Android 平台压缩格式
var androidSettings = atlas.GetPlatformSettings("Android");
androidSettings.overridden = true;
androidSettings.maxTextureSize = 2048;
androidSettings.format = TextureImporterFormat.ASTC_6x6;
atlas.SetPlatformSettings(androidSettings);

// iOS 平台压缩格式
var iosSettings = atlas.GetPlatformSettings("iPhone");
iosSettings.overridden = true;
iosSettings.maxTextureSize = 2048;
iosSettings.format = TextureImporterFormat.PVRTC_RGB4;
atlas.SetPlatformSettings(iosSettings);

AssetDatabase.CreateAsset(atlas, savePath);
return atlas;
}

private static void UpdateAtlasContent(SpriteAtlas atlas, string folderPath)
{
var existing = new HashSet<Object>(atlas.GetPackables());
var toAdd = new List<Object>();

foreach (var file in Directory.GetFiles(folderPath, "*", SearchOption.AllDirectories))
{
if (!file.EndsWith(".png") && !file.EndsWith(".jpg")) continue;

// 转换为相对于 Assets 的路径
string relativePath = "Assets" + file.Replace(Application.dataPath, "").Replace("\\", "/");
var obj = AssetDatabase.LoadAssetAtPath<Object>(relativePath);

if (obj != null && !existing.Contains(obj))
toAdd.Add(obj);
}

if (toAdd.Count > 0)
atlas.Add(toAdd.ToArray());
}
}

运行时动态检查 DrawCall(调试用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#if UNITY_EDITOR
using UnityEngine;
using UnityEngine.Profiling;

// 挂到场景中,运行时在 Console 输出当前帧的渲染统计
public class DrawCallMonitor : MonoBehaviour
{
void Update()
{
// 需要在 Editor 的 Stats 面板查看,或通过 Profiler API
// UnityStats 仅在编辑器下可用
if (Time.frameCount % 60 == 0) // 每 60 帧输出一次
{
Debug.Log($"[渲染统计] 帧: {Time.frameCount} | " +
$"已分配内存: {Profiler.GetTotalAllocatedMemoryLong() / 1024 / 1024} MB");
}
}
}
#endif

注意事项与最佳实践

内存陷阱

图集中只要有一个 Sprite 被引用,整张图集纹理都会加载进内存。所以图集的划分策略很关键:

  • 按功能模块划分(登录界面、大厅界面、战斗界面各一个图集)
  • 避免把低频资源和高频资源放在同一图集
  • 单个图集不要超过 2048×2048,超出部分 Unity 会自动忽略 MaxTextureSize 设置

合批失效的常见原因:

  • 两个 Sprite 来自不同图集
  • 中间插入了使用不同材质的对象(打断批次)
  • 开启了 Tight Packing 导致 UV 计算异常

Sprite Atlas 和 Drawcall

Atlas 本身不减少 Draw Call,它是让 batching 成为可能的前提条件。

Sprite Atlas

图集解决的是纹理问题,把多张小图合并成一张大图。
原来每个 sprite 用不同纹理 → 不同材质 → 每次渲染都要 SetPass Call 切换。合并后所有 sprite 共享同一张纹理 → 同一个材质 → 为后续 batching 创造条件。

1
2
3
4
5
6
7
8
9
没有图集:
图标A (tex_a) → SetPass → Draw
图标B (tex_b) → SetPass → Draw ← 纹理不同,必须切换
图标C (tex_c) → SetPass → Draw

有图集:
图标A (atlas) → SetPass → Draw
图标B (atlas) → Draw ← 同一纹理,可以 batch
图标C (atlas) → Draw

Batching

Static Batching / GPU Instancing 能合并 Draw Call,但不能合并 SetPass Call 。

1
2
3
// GPU Instancing:同一材质的100个物体(DrawCall x 100)
SetPass Call × 1 ← 只切换一次状态
Draw Call × 1 ← 合并成一次绘制(instanced)

而 Dynamic Batching 同时减少两者,因为它把多个 mesh 合并成一个,用同一个材质一次画完。

  • Draw Call 多 → GPU 压力大
  • SetPass Call 多 → CPU 压力大(状态切换开销)
  • 先降 SetPass Call(合并材质、用图集),再降 Draw Call(Batching、Instancing)

Unity Stats 窗口里显示的 “SetPass Calls” 和 “Batches” 分别对应这两个指标,看这两个数比只看 Draw Call 更有参考价值。

Sprite Atlas 如何减少 Draw Call

Sprite Atlas 确实减少 Draw Call,但不是靠自己,而是靠触发 Dynamic Batching。

Draw Call 能被合并的条件是:多个物体使用完全相同的材质(同 shader + 同纹理 + 同参数)。

没有 Atlas 时:

1
2
3
图标A → 材质A (tex_a)  ← 不同纹理 = 不同材质
图标B → 材质B (tex_b) ← 无法 batch
图标C → 材质C (tex_c) ← 3个 Draw Call

有了 Atlas,所有图标共享同一张纹理,材质变成一样的,Dynamic Batching 才能介入把它们合并:

1
2
3
图标A → 材质X (atlas)  ← 同一材质
图标B → 材质X (atlas) ← Dynamic Batching 合并
图标C → 材质X (atlas) ← 1个 Draw Call

所以准确说法是:Atlas 统一了纹理,使 batching 成为可能,batching 才是真正减少 Draw Call 的那一步。 两者缺一不可。

即使不考虑 Draw Call,Atlas 本身也有价值:

  • 减少纹理切换的 SetPass Call,这个开销比 Draw Call 更重
  • 减少内存碎片,GPU 显存里一张大图比十张小图更高效
  • 减少文件 IO,加载一张图比加载十张快

总结

Atlas 的核心价值是”统一材质”,Draw Call 的减少是这个统一带来的副产品,真正执行合并的是 Dynamic Batching。

AssetBundle

AssetBundle 本质是一个压缩的二进制文件,内部结构如下:

1
2
3
4
AssetBundle 文件
├── Header(包头:版本、压缩类型、元数据)
├── Manifest(资源清单:资源名、依赖关系、GUID)
└── Data Segment(序列化的资源数据)

支持两种压缩格式:

  • LZMA:压缩率高,但加载时需整包解压,内存占用大
  • LZ4:块压缩,按需解压,加载更快,推荐使用

依赖关系

AB 包之间存在依赖链,必须按顺序加载:

1
2
3
bundle_character.ab
└── 依赖 bundle_texture.ab
└── 依赖 bundle_shader.ab

如果 bundle_texture.ab 未加载,bundle_character.ab 中的资源会出现材质丢失(粉红色)。

打包脚本(Editor)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Editor/AssetBundleBuilder.cs
using UnityEditor;
using System.IO;

public class AssetBundleBuilder
{
[MenuItem("Tools/Build AssetBundles")]
public static void BuildAllAB()
{
string outputPath = "Assets/StreamingAssets/AssetBundles";
if (!Directory.Exists(outputPath))
Directory.CreateDirectory(outputPath);

// BuildAssetBundleOptions.ChunkBasedCompression 使用 LZ4 压缩
BuildPipeline.BuildAssetBundles(
outputPath,
BuildAssetBundleOptions.ChunkBasedCompression,
BuildTarget.StandaloneWindows64
);

AssetDatabase.Refresh();
UnityEngine.Debug.Log("AssetBundle 构建完成");
}
}

运行时加载管理器

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// Runtime/AssetBundleManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class AssetBundleManager : MonoBehaviour
{
// 缓存已加载的 AB 包,避免重复加载
private Dictionary<string, AssetBundle> _loadedBundles = new();

private string _basePath;

void Awake()
{
_basePath = Application.streamingAssetsPath + "/AssetBundles/";
}

// 同步加载 AB 包(会阻塞主线程,小包可用)
public AssetBundle LoadBundle(string bundleName)
{
if (_loadedBundles.TryGetValue(bundleName, out var cached))
return cached;

// 先加载依赖
LoadDependencies(bundleName);

var bundle = AssetBundle.LoadFromFile(_basePath + bundleName);
if (bundle == null)
{
Debug.LogError($"加载 AB 包失败: {bundleName}");
return null;
}

_loadedBundles[bundleName] = bundle;
return bundle;
}

// 加载依赖包(通过 Manifest 自动解析)
private void LoadDependencies(string bundleName)
{
// Manifest 包名与输出目录同名
var manifestBundle = AssetBundle.LoadFromFile(_basePath + "AssetBundles");
if (manifestBundle == null) return;

var manifest = manifestBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
string[] deps = manifest.GetAllDependencies(bundleName);

foreach (var dep in deps)
{
if (!_loadedBundles.ContainsKey(dep))
{
var depBundle = AssetBundle.LoadFromFile(_basePath + dep);
if (depBundle != null)
_loadedBundles[dep] = depBundle;
}
}

manifestBundle.Unload(false); // 卸载 Manifest 包,保留已加载资源
}

// 异步加载 AB 包(推荐,不阻塞主线程)
public IEnumerator LoadBundleAsync(string bundleName, System.Action<AssetBundle> onComplete)
{
if (_loadedBundles.TryGetValue(bundleName, out var cached))
{
onComplete?.Invoke(cached);
yield break;
}

var request = AssetBundle.LoadFromFileAsync(_basePath + bundleName);
yield return request;

if (request.assetBundle == null)
{
Debug.LogError($"异步加载 AB 包失败: {bundleName}");
yield break;
}

_loadedBundles[bundleName] = request.assetBundle;
onComplete?.Invoke(request.assetBundle);
}

// 从已加载的包中加载具体资源
public T LoadAsset<T>(string bundleName, string assetName) where T : Object
{
var bundle = LoadBundle(bundleName);
return bundle?.LoadAsset<T>(assetName);
}

// 卸载 AB 包
// unloadAllObjects=true:同时卸载从该包加载的所有资源实例(防内存泄漏)
// unloadAllObjects=false:只卸载包本身,已加载的资源继续存在
public void UnloadBundle(string bundleName, bool unloadAllObjects = false)
{
if (_loadedBundles.TryGetValue(bundleName, out var bundle))
{
bundle.Unload(unloadAllObjects);
_loadedBundles.Remove(bundleName);
}
}

// 卸载所有包
public void UnloadAll(bool unloadAllObjects = false)
{
foreach (var bundle in _loadedBundles.Values)
bundle.Unload(unloadAllObjects);
_loadedBundles.Clear();
}
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 使用 AssetBundleManager 加载预制体并实例化
public class GameLoader : MonoBehaviour
{
[SerializeField] private AssetBundleManager _abManager;

IEnumerator Start()
{
// 异步加载包
AssetBundle bundle = null;
yield return _abManager.LoadBundleAsync("characters", b => bundle = b);

if (bundle != null)
{
// 从包中加载预制体
var prefab = bundle.LoadAsset<GameObject>("Hero");
Instantiate(prefab, Vector3.zero, Quaternion.identity);

// 资源实例化后可以卸载包(unloadAllObjects=false 保留实例)
_abManager.UnloadBundle("characters", false);
}
}
}

Addressables

Addressables 是对 AssetBundle 的高层封装,核心组件:

1
2
3
4
5
6
7
8
9
10
11
Addressables 系统
├── Catalog(资源目录)
│ └── 地址(string) → 资源位置(本地/远程) 的映射表
├── ResourceLocator(资源定位器)
│ └── 根据 Catalog 找到资源的实际路径
├── ResourceProvider(资源提供者)
│ └── 负责实际的加载逻辑(本地文件/网络下载)
├── AsyncOperationHandle(异步操作句柄)
│ └── 管理加载状态、进度、回调
└── ResourceManager(资源管理器)
└── 引用计数、内存管理

加载流程

1
2
3
4
5
6
7
8
9
10
11
12
13
Addressables.LoadAssetAsync("Hero")

ResourceLocator 查询 Catalog

找到资源位置(本地 StreamingAssets 或远程 CDN URL)

ResourceProvider 加载对应的 AssetBundle

自动加载所有依赖 Bundle

从 Bundle 中提取目标资源

引用计数 +1,返回 AsyncOperationHandle

引用计数机制

Addressables 内部维护引用计数,每次 LoadAssetAsync 计数 +1,每次 Release 计数 -1,归零时自动卸载 Bundle,这是它比原生 AB 包更安全的核心原因。

基础加载与释放

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// Runtime/AddressablesLoader.cs
using System.Collections;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressablesLoader : MonoBehaviour
{
// 在 Inspector 中直接引用 Addressable 资源(推荐,类型安全)
[SerializeField] private AssetReferenceGameObject _heroPrefabRef;

private AsyncOperationHandle<GameObject> _loadHandle;

void Start()
{
// 方式一:通过 AssetReference(Inspector 拖拽绑定)
LoadViaAssetReference();

// 方式二:通过地址字符串
// LoadViaAddress("Hero");
}

// 通过 AssetReference 加载(推荐)
void LoadViaAssetReference()
{
_loadHandle = _heroPrefabRef.LoadAssetAsync<GameObject>();
_loadHandle.Completed += OnLoadComplete;
}

// 通过地址字符串加载
void LoadViaAddress(string address)
{
_loadHandle = Addressables.LoadAssetAsync<GameObject>(address);
_loadHandle.Completed += OnLoadComplete;
}

private void OnLoadComplete(AsyncOperationHandle<GameObject> handle)
{
if (handle.Status == AsyncOperationStatus.Succeeded)
{
Instantiate(handle.Result, Vector3.zero, Quaternion.identity);
Debug.Log("资源加载成功");
}
else
{
Debug.LogError($"资源加载失败: {handle.OperationException}");
}
}

void OnDestroy()
{
// 必须释放句柄,否则引用计数不归零,内存泄漏
if (_loadHandle.IsValid())
Addressables.Release(_loadHandle);
}
}

异步实例化(更高效,跳过 LoadAsset 步骤)

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
public class AddressablesInstantiator : MonoBehaviour
{
[SerializeField] private string _address;
private AsyncOperationHandle<GameObject> _instantiateHandle;

IEnumerator Start()
{
// InstantiateAsync 直接加载并实例化,一步到位
_instantiateHandle = Addressables.InstantiateAsync(
_address,
Vector3.zero,
Quaternion.identity
);

// 等待完成并显示进度
while (!_instantiateHandle.IsDone)
{
Debug.Log($"加载进度: {_instantiateHandle.PercentComplete:P0}");
yield return null;
}

if (_instantiateHandle.Status == AsyncOperationStatus.Succeeded)
Debug.Log($"实例化成功: {_instantiateHandle.Result.name}");
}

void OnDestroy()
{
// InstantiateAsync 创建的对象用 ReleaseInstance 释放
if (_instantiateHandle.IsValid())
Addressables.ReleaseInstance(_instantiateHandle);
}
}

批量加载(LoadAssetsAsync)

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
public class AddressablesBatchLoader : MonoBehaviour
{
// 通过 Label 批量加载同类资源
[SerializeField] private string _label = "Enemies";

private AsyncOperationHandle<IList<GameObject>> _batchHandle;

IEnumerator Start()
{
_batchHandle = Addressables.LoadAssetsAsync<GameObject>(
_label,
obj => Debug.Log($"单个资源加载完成: {obj.name}") // 每个资源加载完的回调
);

yield return _batchHandle;

if (_batchHandle.Status == AsyncOperationStatus.Succeeded)
{
foreach (var prefab in _batchHandle.Result)
Instantiate(prefab);
}
}

void OnDestroy()
{
if (_batchHandle.IsValid())
Addressables.Release(_batchHandle);
}
}

场景加载

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
34
using UnityEngine.ResourceManagement.ResourceProviders;
using UnityEngine.SceneManagement;

public class AddressablesSceneLoader : MonoBehaviour
{
[SerializeField] private string _sceneAddress = "Level_01";
private AsyncOperationHandle<SceneInstance> _sceneHandle;

public IEnumerator LoadScene()
{
_sceneHandle = Addressables.LoadSceneAsync(
_sceneAddress,
LoadSceneMode.Additive, // 叠加模式,不卸载当前场景
activateOnLoad: true
);

while (!_sceneHandle.IsDone)
{
Debug.Log($"场景加载进度: {_sceneHandle.PercentComplete:P0}");
yield return null;
}

Debug.Log("场景加载完成");
}

public IEnumerator UnloadScene()
{
if (_sceneHandle.IsValid())
{
var unloadHandle = Addressables.UnloadSceneAsync(_sceneHandle);
yield return unloadHandle;
}
}
}

热更新检查(远程内容更新)

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
using System.Collections.Generic;
using UnityEngine.AddressableAssets.ResourceLocators;

public class AddressablesUpdater : MonoBehaviour
{
IEnumerator Start()
{
// Step 1:检查 Catalog 是否有更新
var checkHandle = Addressables.CheckForCatalogUpdates(autoReleaseHandle: false);
yield return checkHandle;

if (checkHandle.Status != AsyncOperationStatus.Succeeded)
{
Addressables.Release(checkHandle);
yield break;
}

List<string> catalogsToUpdate = checkHandle.Result;
Addressables.Release(checkHandle);

if (catalogsToUpdate.Count == 0)
{
Debug.Log("无需更新");
yield break;
}

// Step 2:更新 Catalog
var updateHandle = Addressables.UpdateCatalogs(catalogsToUpdate, autoReleaseHandle: false);
yield return updateHandle;

List<IResourceLocator> updatedLocators = updateHandle.Result;
Addressables.Release(updateHandle);

// Step 3:下载更新的内容
foreach (var locator in updatedLocators)
{
var sizeHandle = Addressables.GetDownloadSizeAsync(locator.Keys);
yield return sizeHandle;

long downloadSize = sizeHandle.Result;
Addressables.Release(sizeHandle);

if (downloadSize > 0)
{
Debug.Log($"需要下载: {downloadSize / 1024f / 1024f:F2} MB");

var downloadHandle = Addressables.DownloadDependenciesAsync(locator.Keys);
yield return downloadHandle;
Addressables.Release(downloadHandle);
}
}

Debug.Log("更新完成");
}
}

At startup, the Instance ID cache is initialized with data for all Objects immediately required by the project (i.e., referenced in built Scenes), as well as all Objects contained in the Resources folder. Additional entries are added to the cache when new assets are imported at runtime or when Objects are loaded from AssetBundles. ( Note: An example of an Asset created at runtime would be a Texture2D Object created in script, like so: var myTexture = new Texture2D(1024, 768); )

  • File GUID(资源UID)
    编辑器层面,存在 .meta 文件里,唯一标识磁盘上的一个资源文件,打包后不直接使用

  • Local ID
    一个资源文件内部,每个 Object 的编号
    比如一个 FBX 里有 Mesh、Material、动画,各有自己的 Local ID

  • Instance ID(实例ID)
    运行时层面,Unity 内存中每个 Object 的唯一标识,本质是一个整数,运行时动态分配
    是 File GUID + Local ID 的运行时映射

1
2
3
4
5
磁盘:  File GUID + Local ID  →  唯一定位一个资源
运行时:Instance ID → 指向内存中的 Object

Instance ID Cache = 这个映射表
{ Instance ID → (File GUID, Local ID, 内存地址或null) }
  • Instance ID Cache 的作用
1
2
3
4
5
6
7
8
9
启动时预填充:
- 所有场景直接引用的资源
- Resources 文件夹下的所有资源
→ 这些 Object 的 Instance ID 提前注册,但不一定加载进内存

运行时追加:
- 从 AB 包加载新资源时
- 代码里 new Texture2D() 创建时
→ 新 Instance ID 动态加入缓存

当你持有一个对象引用,Unity 内部就是拿着 Instance ID 去缓存里查:

  1. 查到且有内存地址 → 直接返回
  2. 查到但没加载 → 触发加载
  3. 查不到 → null(Missing)

总结:Instance ID Cache 是运行时的”对象注册表”,把内存中每个 Object 的整数 ID 映射到它的资源来源(GUID+LocalID)和内存位置。
资源 UID(File GUID)是编辑器/磁盘层面的概念,Instance ID 是运行时层面的概念,两者通过这个缓存关联起来。

Resources

Resources文件夹是一个只读的文件夹,通过Resources.Load()来读取对象。
因为这个文件夹下的所有资源都可以运行时来加载,所以Resources文件夹下的所有东西都会被无条件的打到发布包中。
建议这个文件夹下只放Prefab文件,因为Prefab会自动过滤掉对象上不需要的资源。

  • 举个例子我把模型文件还有贴图文件都放在了Resources文件夹下,但是我有两张贴图是没有在模型上用的,那么此时这两张没用的贴图也会被打包到发布包中。
  • 假如这里我用Prefab,那么Prefab会自动过滤到这两张不被用的贴图,这样发布包就会小一些了。
  • 此时,若prefab引用的贴图在resources文件夹下,则贴图仍然会被打进包中,因为resources文件夹对资源无条件打包。
  • 若prefab引用的贴图在AB包中,则实例化prefab之前,必须手动去把AB加载出来。
  • 就算prefab不是在resources文件来而是在不同于它引用的贴图的AB包中,也一样要在实例化前把贴图所在的AB加载出来;但不用把贴图加载出来,prefab会自动去加载它。

Resources.Load

Unity 打包时为每个资源分配 UID,同时建立一张”路径→UID”映射表存在配置文件里。
加载时先查表把路径转成 UID,再用 UID 找到实际数据。

1
2
3
4
5
Assets/Resources/ui/icon.png
↓ 打包
resources.assets(包含所有未被场景引用的Resources资源)
↓ 运行时
Resources.Load("ui/icon", typeof(Sprite))

特殊情况:如果 Resources 下的资源被场景直接引用,会打进 sharedassets[level].assets 而不是 resources.assets,不同目录下的 Resources 文件夹资源路径最好不重名。

卸载资源时用UnloadUnusedAssets(注意:UnloadAsset 对 Resources 无效)

1
2
3
4
5
6
7
8
9
// 加载
Texture2D tex = Resources.Load("ui/icon", typeof(Texture2D)) as Texture2D;

// 卸载
Resources.UnloadUnusedAssets(); // 正确方式
// Resources.UnloadAsset(tex); 只对 AB 包加载的资源有效

// 查找场景中所有同类型对象(包括未激活的)
Resources.FindObjectsOfTypeAll<GameObject>();

优点:

  • 使用极简,一行代码加载
  • 不需要管理 AB 包依赖

缺点:

  • 所有 Resources 资源无论用没用都打进安装包,包体膨胀
  • 无法热更,改资源必须重新发版
  • 大量资源时启动慢(Unity 启动时要建立完整索引表)

StreamingAssets

StreamingAssets文件夹也是一个只读的文件夹,其目录下的文件在打包时完全不处理,原样复制到目标平台,保留原始文件格式和路径结构。

  • Resources文件夹下的资源会进行一次压缩,而且也会加密,不使用点特殊办法是拿不到原始资源的
  • StreamingAssets文件夹下面的所有资源不会被加密,然后是原封不动的打包到发布包中,这样很容易就拿到里面的文件。所以StreamingAssets适合放一些二进制文件,而Resources更适合放一些GameObject和Object文件。
    • 比如 icon.png 是源文件,放进 Resources 后打包时变成 Texture2D Object,使用Resources.Load<Texture2D>(“icon”)可直接返回 Texture2D 对象
    • 放进 StreamingAssets 它还是原始 .png,Unity 不处理它。

各平台存储位置

1
2
3
4
Windows/Editor  → [项目]/Assets/StreamingAssets/
iOS → [.ipa包]/Data/Raw/
Android → [.apk包]/assets/ (压缩存储在zip内)
WebGL → 无法直接访问

Android 特殊处理(存在 APK 压缩包内,不能直接用文件路径):

1
2
3
4
5
6
7
8
9
10
11
12
// Android 必须用 UnityWebRequest
IEnumerator LoadVideoAndroid(string fileName)
{
string path = Path.Combine(Application.streamingAssetsPath, fileName);
using var req = UnityWebRequest.Get(path);
yield return req.SendWebRequest();
byte[] data = req.downloadHandler.data;
}

// 其他平台可以直接用文件路径
string videoPath = Path.Combine(Application.streamingAssetsPath, "intro.mp4");
videoPlayer.url = videoPath;

优点:

  • 保留原始文件格式,适合需要文件路径的第三方库
  • 视频播放器、SQLite 数据库等可以直接访问
  • 不经过 Unity 资源管道,格式不受限

缺点:

  • Android 上访问慢且必须用 UnityWebRequest
  • 无法热更
  • 不适合大量小文件(没有打包优化)

实际用例:

  • 视频文件(需要 VideoPlayer 直接访问路径)
  • SQLite 数据库初始文件(首次运行复制到 PersistentDataPath 再读写)
  • 第三方 SDK 需要的配置文件
  • AB 包的初始版本(首次运行写入缓存)

PersistentDataPath

由于StreamingAssets文件夹里的内容只能读不能写,所以在需要进行写入操作的时候可以使用PersistentDataPath文件夹,此目录下的文件可读可写,但和StreamingAssets的不同点是:在Editor阶段没有,等到手机安装App后在目标设备上自动生成

  • 可以将一些需要读写的文件先放在Streaming Assets,在App安装后通过代码Copy到PersistentDataPath文件夹,比如上面提到的数据库初始文件

AssetBundle

资源在 Inspector 面板右下角设置包名和变体名,相同包名打进同一个 .ab 文件。

1
2
3
包名: characters/hero    变体名: hd
包名: characters/hero 变体名: sd
→ 生成 characters/hero.hd 和 characters/hero.sd 两个变体包

变体包(Variant)是同一资源的不同版本,用于针对不同设备/平台加载不同质量的内容,但代码层面引用名称保持一致。

根据设备性能决定加载哪个变体
1
2
3
4
5
6
7
8
9
string variant = SystemInfo.systemMemorySize > 4096 ? "hd" : "sd";

// 加载对应变体的 AB 包
string bundlePath = $"characters/hero.{variant}";
AssetBundle ab = AssetBundle.LoadFromFile(bundlePath);

// 资源名称不变,Unity 自动从当前加载的变体中取
GameObject hero = ab.LoadAsset<GameObject>("Hero");
Instantiate(hero);

运行游戏时加载方式:

本地加载和从服务器下载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 本地加载
AssetBundle ab = AssetBundle.LoadFromFile(path);
GameObject prefab = ab.LoadAsset<GameObject>("Hero");

// 网络下载(带缓存)
IEnumerator LoadFromCDN(string url, uint version)
{
using var req = UnityWebRequestAssetBundle.GetAssetBundle(url, version, 0);
yield return req.SendWebRequest();
AssetBundle ab = DownloadHandlerAssetBundle.GetContent(req);
var prefab = ab.LoadAsset<GameObject>("Hero");
Instantiate(prefab);
ab.Unload(false);
}

优点:

  • 支持热更新,资源可以独立于安装包更新
  • 按需加载,减少内存占用
  • 支持变体,同一资源针对不同平台/设备加载不同版本
  • 可以精细控制加载和卸载时机

缺点:

  • 需要管理依赖关系,漏加载依赖包会导致资源丢失,或者依赖未正确处理会产生资源冗余(同一资源打进多个包)
  • 开发流程复杂,需要额外的打包和版本管理系统
  • Unload 使用不当会导致内存泄漏或纹理变洋红

实际用例:

  • 手游热更新(关卡、角色、皮肤)
  • DLC 内容下载
  • 大型游戏按需加载地图/场景
  • 多语言资源包(不同语言加载不同 AB)

现代 Unity 项目的推荐方案是用 Addressables,它是对 AssetBundle 的封装,自动处理依赖、缓存和版本管理,大幅降低 AB 包的使用复杂度。