Unity Primer
零、工作原理
反射机制
[!NOTE] 反射
- 程序正在运行时,可以查看其它程序集或者自身的元数据。一个运行的程序查看本身或者其它程序的元数据的行为就叫做反射
- 在程序运行时,通过反射可以得到其它程序集或者自己程序集中代码的各种信息,比如类,函数,变量,对象等等我们可以实例化它们,执行它们,操作它们
Unity 开发的本质就是在 Unity 引擎的基础上,利用反射和引擎提供的各种功能进行的拓展开发。
场景中对象的本质是什么?
GameObject 类对象是 Unity 引擎提供给我们的,作为场景中所有对象的根本。
在游戏场景中出现一个对象,不管是图片、模型、音效、摄像机等等都是依附于 GameObject 对象。
拟人化记忆: GameObject 就是没有剧本的演员。
除了 Transform 这个表示位置的标配脚本外,我们可以为这个演员 (GameObject)关联各种剧本(脚本 ),让它按照我们剧本中 (代码逻辑中)的命令来处理事情
而为演员添加剧本的这个过程,就是在利用反射 new 一个新的剧本对象和演员 (GameObject)对象进行关联,让其按我们的命令做事。
Unity 场景文件(. unity)它的本质就是一个配置文件
Unity 有一套自己识别处理它的机制,本质就是把场景对象相关信息读取出来,通过反射来创建各个对象关联各个脚本对象
预制体(Prefab)和资源包导入导出
预制体和资源包用于保存数据,方便数据管理,如果更改预制资源,则任何场景中的所有预制资源实例都将以相同的方式更改。
在播放模式下,预制体和实例之间的关系已断开。
一、脚本基础
1 创建规则
- 类名和文件名必须一致, 不然不能挂载 (因为反射机制创建对象,会通过文件名去找 Type)
- 建议不要使用中文名命名
- 没有特殊需求不用管命名空间
- 创建的脚本默认继承 MonoBehavior
默认脚本内容路径:Editor\DataResources\ScriptTemplates
- 脚本之间的关系:
2 特性
特性可以组合在一个 []
中,逗号分隔
[ExecuteAlways]
:令脚本在编辑模式下运行[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
:自动添加需要的组件作为依赖项。[CreateAssetMenu (menuName ="Rendering/CreateCustomRenderPipeline")]
:该标签会让你在 Project 下右键->Create 菜单中添加一个新的子菜单[DisallowMultipleComponent]
:不允许在一个对象上挂相同组件
3 Inspector 窗口
可编辑的变量
[!NOTE]
- Inspector 窗口中的变量关联的就是对象的成员变量,运行时改变他们就是在改变成员变量
- 拖拽到 Gameobject 对象后,再改变脚本代码中变量默认值,界面上不会改变
- 运行中修改的信息不会保存
Inspector 显示的可编辑内容就是脚本的成员变量
public 成员变量可直接显示编辑
加上特性[HideInInspector]
后不可显示编辑1
2[ ]
public int i;private 和 protected 成员变量无法显示和编辑
加上强制序列化字段特性[serializeField]
后可以编辑。所谓序列化就是把一个对象保存到一个文件或数据库字段中去。1
2[ ]
private int z;
[!NOTE] 序列化与反序列化
- 序列化是将对象转换为二进制流的过程。把内存中的数据(类的对象数据)存储到硬盘上。
- 反序列化是将二进制流转换为对象的过程。把硬盘上的数据读取到内存(类的对象数据)中
- 序列化主要解决对象的传输问题。
- 大部分类型都能显示编辑,不支持字典 Dictionary 和自定义类类型变量。
加上序列化[Serializable]
特性后可以显示自定义类类型1
2
3
4
5
6[ ]
public class Person
{
public int age;
public string name;
}
窗口排版
- 分组说明特性 Header:为成员分组
[Header ("分组说明")]
1 | [ ] |
鼠标悬停注释 Tooltip :为变量添加说明
`Tooltip (“说明内容”)]间隔特性 Space:让两个字段间出现间隔
[Space ()]
修饰数值的滑条范围 Range
[Range (最小值, 最大值)]
多行显示字符串,默认不写参数显示 3 行,写参数就是对应行
[Multiline (行数)]
滚动条显示多行 字符串,默认不写参数就是超过 3 行显示滚动条
[TextArea (3,4)]
:最少显示 3 行,最多 4 行,超过 4 行就显示滚动条为变量添加快捷方法 contextMenuItem
- 参数 1 显示按钮名
- 参数 2 方法名不能有参数
[contextMenuItem ("显示按钮名",“方法名")]
右键可以查看方法:1
2
3
4
5
6
7[ ]
public int money;
private void ResetMoney()
{
money = 0;
}
为方法添加特性能够在 Inspector 中执行 ContextMenu
[ContextMenu ("测试函数")]
1
2
3
4
5[ ]
private void TestFun()
{
print("哈哈哈哈");
}在脚本上可以调用该方法:
4 生命周期函数
游戏的本质就是一个死循环(Tick),每一次循环处理游戏逻辑就会更新一次画面,一帧就是执行一次循环。
Unity 底层已经帮助我们做好了死循环,我们需要学习 Unity 的生命周期函数,利用它做好的规则来执行我们的游戏逻辑就行了。
[!NOTE] 生命周期函数的概念
- 所有继承 MonoBehavior 的脚本最终都会挂载到 Gameobject 游戏对象上
- 生命周期函数就是该脚本对象依附的 Gameobject 对象从出生到消亡整个生命周期中会通过反射自动调用的一些特殊函数
- Unity 帮助我们记录了一个 Gameobject 对象依附了哪些脚本,会自动的得到这些对象,通过反射去执行生命周期函数
- 生命周期函数并不是 MonoBehavio 基类中的成员,Unity 帮助我们记录了场景上的所有 GameObjgct 对象以及各个关联的脚本对象,在游戏执行的特定时机 (对象创建时,失活激活时,帧更新时)它会通过函数名反射得到脚本对象中对应的生命周期函数,然后再这些特定时机执行他们
- 生命周期函数的访问修饰符一般为 private 和 protected(默认为private)
- 因为不需要再外部自己调用生命周期函数都是 Unity 自己帮助我们调用的
- 支持继承多态
常用的生命周期函数:
![]()
1 | //当对象(自己找个类对象)被创建时,才会调用该生命周期函数 |
激活对象:
`
设置物理帧固定时间步长:
5 随机数
Unity 当中的 Random 类和 cs 中的 Random 类不同。
使用 cs 自带随机数加上 System. 就可以
1 | //随机数 int 重载规则是左包含,右不包含 [) |
1 | System.Random r = new System.Random(); |
6 委托/事件
[[《CS Primer》#八、委托 delegate]]
Unity 的委托和 cs 的 Action 委托使用方法类似
1 | UnityAction ac1 = () => { print("test1"); }; //无参无返回值 |
使用 cs 自带委托加上 System. 就可以
1 | System.Action ac1 = () => { print("test1"); }; //无参无返回值 |
事件:和 cs 一样
1 | public event UnityAction clickEvent; |
自定义事件类继承 UnityEvent
:
1 | [ ] |
7 数学 Mathf
Math 是中封装好的用于数学计算的工具类,位于 system 命名空间中
Mathf 是 unity 中封装好的用于数学计算的工具结构体,位于 UnityEngine 命名空间中,Mathf 更适合游戏开发,功能更多
常用运算函数
PI
Abs
取绝对值CeilToInt
向上取整FloorToInt
向下取整RoundToInt
四舍五入Clamp
钳制Max
最大值Min
最小值Pow
幂Sqrt
平方根IsPowerOfTwo
判断一个数是否是 2 的 n 次方Sign
判断正负数,返回 1/-1
Lerp
线性插值Vector3.SLerp
球形插值
1 | //Lerp原理 |
三角函数
1 | //弧度转角度 |
向量
1 | //Vector3的初始化 |
欧拉角
[[01 三维旋转#欧拉角]]
一共有 3 种欧拉角:俯仰角 (Pitch)、偏航角 (Yaw)和滚转角 (Roll)
inspector 界面上显示的 Rotation 的 XYZ 值都是欧拉角
Untiy 欧拉角常用顺规:YXZ(Yaw-pitch-Roll)
使用欧拉角的两个缺点:
- 同一旋转表示不唯一,即欧拉角绕一个轴旋转 90° 和 450°结果是一样的
- X 轴达到 90 度时会产生万向节死锁
使用四元数可以解决这两个问题,四元数的旋转转换为欧拉角后可以发现对应的欧拉角范围为(-180~180),不会出现欧拉角的缺点一。
四元数
[[01 三维旋转#四元数]]
四元数构成
一个四元数包含一个标量和一个 3D 向量 $[ v,w]$
其中 $v$ 为 3D 向量, $w$ 为标量,即 $[(x, y, z),w]$
对于给定的任意一个四元数: 表示 3D 空间中的一个旋转量
[!NOTE] 轴-角对
在 3D 空间中,任意旋转都可以表示绕着某个轴旋转一个旋转角得到
注意: 该轴是局部空间中的任意一个轴
对于给定旋转,假设为绕着 $n$ 轴,旋转$β$度,$n$ 轴为$(x, y, z)$那么可以构成四元数为
四元数 $Q= [\sin (β/2)*n,\cos (β/2)]$
四元数 $Q= [ \sin (β/2) *x, \sin (β/2) *y, \sin (β/2) *z,\cos (β/2)]$
四元数 $Q$ 则表示绕着轴 $n$,旋转$β$度的旋转量
Unity 中的四元数
1 | //方法一: |
[!warning]
我们一般不会直接通过四元数的 w, x, y, z 进行修改,直接赋值给.transform.rotation
即可
四元数/欧拉角转换
1 | //欧拉角转四元数 |
四元数常用方法
单位四元数
单位四元数表示没有旋转量(角位移)
当角度为 0 或者 360 度时对于给定轴都会得到单位四元数
$[(0, 0,0),1]$ 和 $[(0, 0, 0),1]$ 都是单位四元数,表示没有旋转量
1 | //将rotation改为了(0,0,0) |
四元数插值
四元数中同样提供如同 Vector3 的插值运算 Lerp
和 Slerp
在四元数中 Lerp
和 Slerp
只有一些细微差别由于算法不同
Slerp
的效果会好一些Lerp
的效果相比Slerp
更快但是如果,旋转范围较大则效果较差,所以建议使用 Slerp 进行插值运算1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21public Transform target; //目标位置
public Transform A;
public Transform B;
public Quaternion start;
public float time;
void Start()
{
start = B.transform.rotation;
}
void Update()
{
//无限接近目标的旋转状态,先快后慢
A.transform.rotation = Quaternion.Slerp(A.transform.rotation, target.rotation, Time.deltaTime);
print(Time.deltaTime);
//匀速变化,time>=1到达目标
time += Time.deltaTime;
B.transform.rotation = Quaternion.Slerp(start, target.rotation, time);
}
向量方向转四元数
LookRoataion
方法可以将传入的面朝向量转换为对应的四元数角度信息
举例: 当人物面朝向(图中 A 的面朝向上方)想要改变时,只需要把目标面朝向($\vec{AB}$)传入该函数,便可以得到目标四元数角度信息,之后将人物四元数角度信息改为得到的信息即可完成到转向。
1 | //A看向B |
四元数相乘
四元数相乘代表旋转四元数
1 | //绕y轴转30 |
向量左乘四元数
向量左乘四元数返回一个新向量
可以将指定向量旋转对应四元数的旋转量,相当于旋转向量
1 | Vector3 v = Vector3.forward; |
应用:比如在游戏中我们的技能向四周发射,只需要知道人物的面向向量,然后左乘四元数,就可以得到不同角度的向量。
8 坐标转换
坐标系
世界坐标系
1 | this.transform.position; |
局部坐标系
1 | //相对父对象的物体坐标系的位置本地坐标相对坐标T/ |
屏幕坐标系
1 | Input.mousePosition |
视口坐标系
视口坐标系是与屏幕坐标系息息相关的,它是将 Game 视图的屏幕坐标系单位化,即左下角为 (0, 0),右上角为 (1, 1),z 轴坐标是相机的世界坐标中 z 轴坐标的负值。
注意这和观察坐标系(以摄像机为原点)不同!
局部/世界
1 | //世界坐标系的点转换为局部坐标系点(会受缩放影响) |
以下是从正 Y 轴向下看的视角,中间有一个 Cube 模型
世界坐标系的点 P (0,0,1)转换到局部空间,则 P 点坐标的 x,z 在局部空间为负数。
世界坐标系的向量 P(0,0,1)转换为局部空间,将左边的向量平移到右边,可以观察到该方向向量的 x 为负数,z 为证书
1 | //⭐点(受缩放影响) |
其中最重要的就是局部坐标系的点转世界坐标系的点
比如现在玩家要在自己面前的 n 个单位前放一团火,这个时候我不用关心世界坐标系
通过相对于本地坐标系的位置转换为世界坐标系的点,进行特效的创建或者攻击范围的判断,
世界/屏幕
1 | //世界坐标转屏幕坐标 |
世界/视口
1 | //世界坐标转视口坐标 |
视口空间是标准化的、相对于摄像机的空间。视口左下角为 (0,0),右上角为 (1,1)。z 位置为与摄像机的距离,采用世界单位。
视口/屏幕
1 | //视口转屏幕 |
二、重要组件和 API
0 MonoBehavior 基类
- 创建的脚本默认都继承 MonoBehaviour,继承了它才能够挂载在 GameObject
当我们把脚本拖到 GameObject 上时,引擎会根据文件名通过反射得到对应的类,如果该类继承了 MonoBehaviour,则允许挂载。
- 继承了 MonoBehavior 的脚本不能 new ,只能挂载!
- 继承了 MonnBehavior 的脚本不要去写构造函数,因为我们不会去 new 它,写构造函数没有任何意义
- 继承了 MonoBehavior 的脚本可以在一个对象上挂多个 (如果没有加
DisallowMultipleComponent
特性) - 继承 MonoBehavior 的类也可以再次被继承,遵循面向对象继承多态的规则
不继承 MonoBehaviour 的类:
- 不能挂载在 GameObject 上
- 想怎么写怎么写,如果要使用需要自己 new
- 一般是单例模式的类(用于管理模块)或者数据结构类( 用于存储数据)
- 不用保留默认出现的几个函数
[!info] this
this 代表脚本对象
this.gameobject 代表脚本挂载的 GameObject
this.transform 代表脚本挂载的 GameObject 的位置信息
等价写法:this. gameobject.transform
调试打印
在 Unity 中打印信息的两种方式
1 | //1.没有继承MonoBehaviour的类的时候,可以使用Debug.Log |
1 | //画线段 |
获取脚本挂载的对象
- 获取依附的 Gameobject
- 获取依附的 Gameobject 的位置信息
- 获取脚本是否激活
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public TestScript testScript; //其他脚本
void Start()
{
//1. 获取依附的GameObject
print(this.gameObject.name);
//2. 获取依附的GameObject的位置信息
//得到对象位置信息
print(this.transform.position); //位置
print(this.transform.eulerAngles); //角度
print(this.transform.lossyScale); //缩放大小
//等价写法:this.gameObject.transform
//3. 获取脚本是否激活
this.enabled = true; //激活脚本
this.enabled = false; //禁用脚本
//获取别的脚本对象依附的gameobject和transfrom位置信息
print(testScript.gameObject.name);
print(testScript.transform.position);
}
获取对象挂载的脚本
如何得到依附的 GameObject 对象上挂载的其它脚本?
- 得到 GameObject 挂载的单个脚本
title:得到自己挂载的单个脚本 h:8 1
2
3
4
5
6
7
8
9
10//根据脚本名获取,较少使用
TestScript t1 = this.GetComponent("TestScript") as TestScript;
//根据Type获取
TestScript t2 = this.GetComponent(typeof(TestScript)) as TestScript;
//⭐根据泛型获取,建议使用,不用as
TestScript t3 = this.GetComponent<TestScript>();
//只要你能得到场景中对象或者对象依附的脚本,那你就可以获取到它所有信息
安全的获取脚本,加一个判断:
1 | //方法一: |
得到 GameObject 挂载的多个脚本 (不常用,通常我们不会将同一个脚本挂载两次在同一个 GameObject 上)
1
2
3
4
5
6//方法一
MyScript[] scripts = this.GetComponents<MyScript>();
//方法二
List<MyScript> scriptList = new List<MyScript>(); //定义一个存放MyScript类型的List
this.GetComponents<MyScript>(scriptList); //将找到的结果存在List中得到 GameObject 子孙对象挂载的脚本(默认会先找本 GameObject 对象是否挂载该脚本)
1
2
3
4
5
6
7
8
9
10//得到子孙对象挂载的单个脚本:
MyScript s1 = this.GetComponentInChildren<MyScript>(); //如果脚本失活,则无法找到
MyScript s2 = this.GetComponentInChildren<MyScript>(true); //true表示即使脚本失活,也可以找到
//得到子孙对象挂载的多个脚本:
//方法一:
MyScript[] ss1 = this.GetComponentsInChildren<MyScript>(true);
//方法二:
List<MyScript> ss2 = new List<MyScript>();
this.GetComponentsInChildren<MyScript>(true, ss2);得到 GameObject 长辈(包括父,爷爷…)对象挂载的脚本(默认会先找本 GameObject 对象是否挂载该脚本)
1
2
3
4
5//得到单个脚本
MyScript s3 = this.GetComponentInParent<MyScript>();
//得到多个脚本
MyScript[] ss3 = this.GetComponentsInParent<MyScript>(true);
延迟函数
延迟函数就是会延时执行的函数,是 MonoBehaviour 基类中实现好的方法
我们可以自己设定延时要执行的函数和具体延时的时间
Invoke
延迟执行函数
参数一: 函数名字符串
参数二: 延迟时间以秒为单位
InvokeRepeating
延迟重复执行函数
参数一: 函数名字符串
参数二: 第一次执行的延迟时间
参数三: 之后每次执行的间隔时间
注意:
- 延迟函数第一个参数传入的是函数名字符串
- 延迟函数不能直接执行有参数的函数(无法传参),可以包裹一层来执行(即在一个延迟函数中调用目标有参函数)。
- 函数名必须是该脚本上申明的函数,可以包裹一层来执行
- 脚本依附对象失活,延迟函数可以继续执行
- 脚本依附对象销毁或者脚本移除,延迟函数无法继续执行
- 可以配合 OnEnable 和 OnDIsable 生命周期函数使用
1 | void Start() |
配合周期函数使用:
1 | private void QnEnable() |
取消延迟函数
- 取消该脚本上所有延迟函数
CancelInVoke()
- 取消指定延迟函数
CancelInVoke("函数名")
判断是否有延迟函数if(IsInVoking())
:针对所有延迟函数if(IsInVoking("函数名"))
:针对指定延迟函数
1 Object 类
Object 是 Gameobject 的父类
- unity 里面的 Object 不是指的 cs 中的万物之父 object(cs 中的 object 命名空间是 system )
- unity 里的 Object 命名空间是 UnityEngine ,也是继承万物之父的一个自定义类
2 GameObject 类
成员变量
1 | //名字 |
静态方法
[!warning]
如果是继承 MonoBehaviour 的类,可以不加.GameObject
前缀
查找对象
得到某一个单个对象目前有 2 种方式
- 是 public 从外部面板拖进行关联(推荐)
- 通过 API 去找
以下方法通过 API 去找:
- 只能找到被激活的对象
- 如果场景中存在多个满足条件的对象 (比如同名、同 tag),无法准确找到是谁
1 | //创建几何体 |
实例化对象(Clone)
实例化对象 (克隆对象)的方法
作用:根据一个对象创建出一个和它一模一样的对象
1 | //准备克隆的GameObject |
- 在调用 Instantiate()方法创建对象时,接收 Instantiate()方法返回值的变量类型必须和传入参数的类型一致,否则接收变量的值会为 null.
- 这是一个重载函数,支持任何 Object 类及其子类,可以传多个参数来设置初始的位置和父对象
删除对象
Destroy
方法不会马上移除对象,一般情况下它会在下一帧时把这个对象移除并从内存中移除
1 | //删除GameObject对象 |
成员方法
创建GameObject
1 | //创建空GameObject |
添加/获取脚本
1 | //为对象添加脚本 |
标签比较
1 | //标签比较 |
激活失活
1 | //设置激活失活 |
发送消息
以下方法不建议使用,效率比较低
通过广播或者发送消息的形式,让自己或者别人执行某些行为
1 | void Start() |
3 Time 类
1 | void Update() |
1 | private void FixedUpdate() |
4 Transform 类
游戏对象(Gameobject)位移、旋转、缩放、父子关系、坐标转换等相关操作都由它处理,它是 unity 提供的极其重要的类
Transform 和 GameObject 的区别
当我们使用 Instantiate()
创建 prefab 对象时,有如下两种方法,都可以创建出对象。区别在哪?
- 使用 GameObject
1
2
3
4
5
6
7
8
9
10
11
12
13//使用Gameobject
public class GameObjectTransformTesting : MonoBehaviour
{
[private Gameobject prefab; ]
private void Update( )
{
if ( 工nput.GetKeyDown( Keycode.T))
{
Instantiate(prefab);
}
}
} - 使用 Transfrom
1
2
3
4
5
6
7
8
9
10
11
12//使用Transform
public class GameObjectTransformTesting : MonoBehaviour
{
[private Transform prefab; ]
private void Update( )
{
if ( 工nput.GetKeyDown( Keycode.T))
{
Instantiate(prefab);
}
}
}
注意:
- 任何对象都必须有 Transform 组件
- ⭐我们可以使用 transform. gameobject 来获取 Gameobject 对象,也可以用 gameobject. transform 来获取 Transfrom 组件
Instantiate ()
是泛型函数,参数填什么类型就返回什么类型
从第二点就可以看出来,它门可以相互转换,所以实际上用谁区别不大,都可以拿到我们想要的数据。
通常我们会对对象进行位置变换,可以优先使用 Transfrom,这样就可以避免 gameobject. transfrom 这一步。
涉及对对象本身的设置,如激活、销毁,就优先用 gameobject。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class GameObjectTransformTesting : MonoBehaviour
{
[private Transform prefab; ]
//[serializeField] private Gameobject prefab;
private void Update( )
{
if ( 工nput.GetKeyDown( Keycode.T))
{
Transform prefabTransform = Instantiate(prefab);
//Gameobject prefabGameobject = Instantiate(prefab);
prefabTransform.position = Vector3.zero;
//prefabGameobject.transform.position = Vector3.zero;
...
}
}
}
位置
[!NOTE] Inspector 面板上的 Transfrom 信息
对于父对象来说,positon 是世界空间位置
对于子对象来说,position 是相对于父对象的位置,即在父对象为原点的局部空间中位置
1 | //世界空间位置 |
[!NOTE] 理解 this.transform.forward 和 Vector3.forward 的区别
现在新建一个物体,假设它的世界坐标系是这样的:(刚刚创建的物体本地坐标系也和世界坐标系重合)
现在将物体绕 y 轴顺时针旋转一定角度。
现在黑色坐标系是世界坐标系,红色坐标系是物体旋转后的本地坐标系(因为是绕 y 轴转所以 y 轴不动,就不标红了)。
this.transform.forward
是指对象局部空间的朝向,即图中红 Z
Vector3.forward
是指向量 $(0,0,1)$,和图中黑 Z
方向一致
这两个向量虽然指向的相对位置不同,但是得到的数值都是相对于世界坐标下的!this.transform.forward
虽然是指对象局部空间的朝向,红 Z
在局部空间为 $(0,0,1)$,但我们得到的数值是转换到世界空间的数值!
位移
实现位移的四种方式:
需要联动 [[《Unity Primer》#5 Input 类]]
1 | //理解坐标系下的位移计算公式 |
角度和旋转
1 | //和角度设置一样,不能单独设置x,y,z |
1 | void Update() |
缩放和LookAt
1 | //相对世界坐标系的缩放大小只能得,不能改 |
1 | this.transform.LookAt(Vector3.zero); //看向点 |
父子关系
获取和设置父对象
1 | //获取父对象 |
获取子对象
1 | //按名字查找儿子 |
儿子的操作
1 | //判断是不是我的儿子 |
自定义拓展方法
为 Transform 写一个拓展方法,可以将它的子对象按名字的长短进行排序改变他们的顺序名字短的在前面,名字长的在后面。
title:tool.cs 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//写一个Transfrom类的拓展方法
public static class Tools
{
//为Transform添加一个拓展方法
//可以将它的子对象按名字的长短进行排序改变他们的顺序,名字短的在前面,名字长的在后面
public static void Sort(this Transform obj)
{
List<Transform> list = new List<Transform>();
for (int i = 0; i < obj.childCount; i++)
{
list.Add(obj.GetChild(i));
}
//这是根据名字长短进行排序利用的是list的排序
list.Sort((a, b) =>
{
if(a.name.Length < b.name.Length)
return -1;
else if(a.name.Length > b.name.Length)
return 1;
else
return 0;
});
//根据list中的排序结果重新设置每一个对象的索引编号
for (int i = 0; i < list.Count; i++)
{
list[i].SetSiblingIndex(i);
}
}
}
//然后在父对象挂载的脚本中调用即可
void Start()
{
this.transform.Sort();
}请为 Transform 写一个拓展方法,传入一个名字查找子对象,即使是子对象的子对象也能查找到
title:tool.cs 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23public static Transform CustomFind(this Transform father, string childName)
{
//要找的子对象
Transform target = null;
//先从自己身上的子对象找
target = father.Find(childName);
if (target != null)
return target;
//如果自己身上没有,就从自己的子对象的子对象找
for (int i = 0; i < father.childCount; i++)
{
//递归
target = father.GetChild(i).CustomFind(childName);
if (target != null)
return target;
}
return target;
}
//然后在父对象挂载的脚本中调用即可
print(this.transform.CustomFind("aaa").name);
5 Input 类
输入相关内容都写在 Update 中
鼠标键盘输入
1 | //鼠标在屏幕上的位置 |
1 | //键盘按下 |
1 | //任意键 按下 |
默认轴输入
我们学习鼠标键盘输入主要是用来控制玩家,比如旋转位移等等,所以 unity 提供了更方便的方法来帮助我们控制对象的位移和旋转。
1 | //鼠标AD按下时,返回-1到1之间的浮点值 |
移动设备
1 | //移动设备触摸相关 |
手柄输入
1 | //得到连接的手柄的所有按钮名字 |
6 Screen 类
静态属性
1 | //当前设备屏幕分辨率 |
静态方法
1 | //设置分辨率 |
8 场景
场景同步切换
1 | //场景切换,指定的场景必须先在构建设置中加入 |
场景异步切换
在切换场景时,Unity 会删除当前场景上所有对象,并且去加载下一个场景的相关信息
如果当前场景对象过多或者下一个场景对象过多,这个过程会非常的耗时会让玩家感受到卡顿,异步切换就是来解决该问题的,开一个子线程去加载,加载好后存入公共容器。
场景异步加载和资源异步加载几乎一致,有两种方式:
通过事件回调函数异步加载
title:通过事件回调函数异步加载 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19private void Start()
{
AsyncOperation ao = SceneManager.LoadSceneAsync("Scenename");
//当场景异步加载结束后就会自动调用该事件函数,我们如果希望在加载结束后做一些事情,那么就可以在该函数中,写处理逻辑
//普通形式
ao.completed += LoadOver;
//等价,lambda表达式形式
ao.completed += (a) =>
{
print("加载结束");
};
}
private void LoadOver(AsyncOperation ao)
{
print("加载结束");
}通过协程异步加载
需要注意的是加载场景会把当前场景没有特别处理的对象都删除了,所以协程中的部分逻辑可能是执行不了的
解决思路:使用GameObject.DontDestroyOnLoad()
方法让处理场景加载的脚本依附的对象过场景时不被移除1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22private void Start()
{
GameObject.DontDestroyOnLoad(this.gameObject);
StartCoroutine(LoadScene("Scenename"));
}
IEnumerator LoadScene(string sceneName)
{
//异步加载场景
AsyncOperation ao = SceneManager.LoadSceneAsync(sceneName);
GameObject.DontDestroyOnLoad(this.gameObject);
yield return ao; //Unity自己知道该返回值意味着你在异步加载资源
//Unity 会自己判断该场景是否加载完毕了,加载完毕过后才会继续执行后面的代码
//加载完毕后执行其他逻辑
print("加载结束"); //无法执行,因为切换场景后,上一场景中的所有对象都会被删除,该脚本自然无法继续执行。
//我们可以在Start()函数中使用GameObject.DontDestroyOnLoad方法,让该脚本依附的对象过场景不被删除!这样就可以正常执行
}
协程的优点是异步加载场景时我可以在加载的同时做一些别的逻辑 (写在 yield return
上面,通过事件回调函数异步加载的方法只能在加载结束后执行其他逻辑),比如我们可以在异步加载过程中去更新进度条。
1 | while(!ao.isDone) |
当然不是说必须用异步方法更新进度条(这种方法实际不准确),要根据你游戏的规则自己定义进度条变化的条件,根据需求选择,没有谁好谁坏:
1 | yield return ao; |
他们的优缺点表现和资源异步加载也是一样的
- 事件回调函数
优点: 写法简单,逻辑清晰
缺点: 只能加载完场景做一些事情不能再加载过程中处理逻辑 - 协程异步加载
优点: 可以在加载过程中处理逻辑,比如进度条更新等
缺点: 写法较为麻烦,要通过协程
9 鼠标Cursor
1 | //显示/隐藏鼠标 |
10 LineRenderer
LineRenderer 是 Unity 提供的一个用于画线的组件,使用它我们可以在场景中绘制线段
一般可以用于
- 绘制攻击范围
- 武器红外线
- 辅助功能
- 其它画线功能
组件功能
编辑模式:
使用受光影响的材质时,勾选 Generate Lighting Data
代码相关
所有参数都可以通过代码控制
1 | //动态添加一个线段 |
三、核心系统
2 音频系统
常用格式:wav,mp3,ogg,aiff
属性设置
音频源 Audio Source
- 一个 Scene 内 Audio Source 只能有一个
- 一个 Gameobject 可以挂载多个音效源脚本 AudioSource
- 使用时要注意如果要挂载多个,那一定要自己管理他们,控制他们的播放停止,不然我们没有办法准确的获取谁是谁
Spatial Blend:设置 3D 音效,默认为 2D
Volume Rolloff:声音距离衰减
1 | AudioSource audioSource; |
如何动态控制音效播放
- 直接在要播放音效的对象上挂载脚本控制播放
- 实例化挂载了音效源脚本的对象
- 用一个 Audio Clip 来控制播放不同的音效
title:动态控制音效播放 1
2
3
4
5
6
7public AudioClip clip;
void Start()
{
AudioSource audioSource = this.GetComponent<AudioSource>();
audioSource.clip = clip;
audioSource.Play();
}
麦克风设备
1 | // 获取设备麦克风信息 |
3 物理系统
(1) 碰撞检测
物理信息的更新和 FixedTime 相关
[!bug] 碰撞产生的必要条件:
- 两个物体都有碰撞器 Collider
- 至少一个物体有刚体 Rigidbody
刚体 Rigidbody
插值运算:
碰撞检测:
性能消耗关系
Continuous Dynamic > Continuous Speculativec > Continuous > Discrete
约束:
游戏中防止物体乱飞,可以这样设置:
碰撞器 Collider
异形物体使用多种碰撞器组合,刚体对象的子对象碰撞器信息参与碰撞检测(即我们可以给父对象添加刚体,碰撞器则添加到每个子对象上)
不常用的碰撞器:
Mesh Colider网格碰撞器,根据网格生成碰撞体,消耗较大,较为精确
Wheel Colider 车轮碰撞器,用于汽车
Terrain Colider:地形碰撞器
物理材质
让两个物体之间碰撞时表现出不同效果
右键 Create
碰撞检测函数
- 碰撞和触发响应函数属于特殊的生命周期函数,位于 FixedUpdate 和 Update 之间,也是通过反射调用
- 如果是一个异形物体,刚体在父对象上,如果你想通过子对象上挂脚本检测碰撞是不行的必须挂载到这个刚体父对象上才行。
- 碰撞和触发器函数都可以写成虚函数,在子类去重写逻辑
物理碰撞检测响应函数
1 | //Collision类型的参数包含了碰到自己的对象的相关信息 |
1 | //碰撞触发接触时会自动执行这个函数 |
触发器检测响应函数
勾选 IS Trigger
用法类似物理碰撞检测函数:
1 | //第一次接触时 |
刚体加力
给刚体加力的目标就是让其有一个速度朝向某一个方向移动
刚体添加力
1 | //1.首先应该获取刚体组件 |
力的模式
上面添加力的方法其实有第二个参数,用来指定计算力的模式 ForceMode
1 | rigidBody.AddForce(Vector3.forward * 10,ForceMode.Acceleration); |
动量定理 :
Ft =mv
V=Ft/m;
F:力
t:时间
m:质量
v:速度
四种模式:第二种模式比较符合真实
刚体休眠
比如运行游戏后,Cube 落到平面上发生碰撞停下,此时编辑平面的角度,发现 Cube 并没有下落,因为此时 Cube 的刚体休眠了。再移动一下,才会唤醒
1 | if(rigidBody.IsSleeping()) |
力场脚本 Constant Force
更方便的添加力
(2) 范围检测
游戏中瞬时的攻击范围判断一般会使用范围检测
- 玩家在前方 5m 处释放一个地刺魔法,在此处范围内的对象将受到地刺伤害
- 玩家攻击,在前方 1 米圆形范围内对象都受到伤害
类似这种并没有实体物体只想要检测在指定某一范围是否让敌方受到伤害时,便可以使用范围判断。
简而言之,在指定位置进行范围判断我们可以得到处于指定范围内的对象,目的是对对象进行处理,比如受伤减血等等
[!bug] 范围检测必备条件
想要被范围检测到的对象必须具备碰撞器
注意点:
- 范围检测相关 API 只有当执行该句代码时进行一次范围检测,它是瞬时的
- 范围检测相关 API 并不会真正产生个碰撞器,只是碰撞判断计算而已
盒状范围检测
- 参数一: 立方体中心点
- 参数二: 立方体三边大小
- 参数三: 立方体角度
- 参数四: 检测指定 Layer (不填检测所有层)
- 参数五: 是否忽略触发器
- UseGlobal 使用全局设置
- 全局设置根据 Physics 中的设置来决定
- 全局设置根据 Physics 中的设置来决定
- Collide 检测触发器
- Ignore 忽略触发器
- 不填默认使用 UseGlobal
- UseGlobal 使用全局设置
- 返回值: 在该范围内的触发器 (得到了对象触发器就可以得到对象的所有信息)
1 | Collider[] colliders = Physics.OverlapBox( |
另一个 API:Physics.OverlapBoxNonAlloc
参数区别:第三个参数传入一个Collider[]
数组进行存储
返回值回值:碰撞到的碰撞器数量
1 | Collider[] colliders = new Collider[10]; //数组数量必须等于检测到的碰撞体数量 |
[!NOTE] 关于 Layer 编号
- 通过名字得到层级编号可以使用
LayerMask.NameToLayer
方法- 我们需要通过编号左移
<<
构建二进制数,这样每一个编号的层级都是对应位为 1 的 2 进制数,我们通过位运算可以选择想要检测层级- 好处:一个 int 就可以表示所有想要检测的层级信息
也可以直接声明一个 LayerMask 类型的变量,可以开放到 inspector 方便方便调整,但使用位掩码方法更好。为什么?
球体范围检测
- 参数一: 球体中心点
- 参数二: 球半径
- 参数三: 检测指定 Layer (不填检测所有层)
- 参数四: 是否忽略触发器
- UseGlobal 使用全局设置
- Collide 检测触发器
- Ignore 忽略触发器
- 不填默认使用 UseGlobal
- 返回值: 在该范围内的触发器 (得到了对象触发器就可以得到对象的所有信息)
1 | Collider[] colliders = Physics.OverlapSphere( |
另一个 API: Physics.OverlapSphereNonAlloc
返回值: 碰撞到的碰撞器数量
参数: 传入一个数组进行存储
1 | Collider[] colliders = new Collider[10]; //数组数量必须等于检测到的碰撞体数量 |
胶囊范围检测
- 参数一: 上半圆中心点(两个中心点确定胶囊体的位置)
- 参数二: 下半圆中心点
- 参数三: 半圆半径
- 参数四: 检测指定 Layer (不填检测所有层)
- 参数五: 是否忽略触发器
- UseGlobal 使用全局设置
- Collide 检测触发器
- Ignore 忽略触发器
- 不填默认使用 UseGlobal
- 返回值: **在该范围内的触发器 (得到了对象触发器就可以得到对象的所有信息)
title:Physics.OverlapCapsule 1
2
3
4
5
6
7
8
9
10
11Collider[] colliders = Physics.OverlapCapsule(
Vector3.zero,
Vector3.up,
5,
1 << LayerMask.NameToLayer("UI"),
QueryTriggerInteraction.UseGlobal);
for(int i=0;i<colliders.Length;i++)
{
Debug.Log(colliders[i].gameObject.name); //打印触发器挂载的对象信息
}
另一个 API:Physics.OverlapCapsuleNonAlloc
返回值:碰撞到的碰撞器数量
参数:传入一个数组进行存储
1 | Collider[] colliders = new Collider[10]; //数组数量必须等于检测到的碰撞体数量 |
(3) 射线检测
射线检测通过在指定点发射一个指定方向的射线,判断该射线与哪些碰撞器相交,得到对应对象。
声明射线
- @ 指定起点方向的射线
参数一: 起点ray.origin
参数二:方向ray.direction
(不是两点决定射线方向,第二个参数直接就代表方向向量)
1 | //声明射线 |
- @ 摄像机发出的射线
title:摄像机射线 1
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
Physics 类中提供了很多进行射线检测的静态函数
他们有很多种重载类型我们只需要掌握核心的几个函数其它函数自然就明白什么意思了注意:
射线检测也是瞬时的了执行代码时进行一次射线检测
检测是否相交
Raycast
射线投射
进行射线检测如果碰撞到对象返回 true(只检测是否碰撞,得不到信息)
- 参数一: 射线(或直接传入射线起点和方向)
- 参数二: 检测的最大距离,超出这个距离不检测
- 参数三: 检测指定层级 (不填检测所有层)
- 参数四: 是否忽略触发器
- UseGlobal 使用全局设置
- Collide 检测触发器
- Ignore 忽略触发器
- 不填默认使用 UseGlobal
- 返回值: bool 当碰撞到对象时返回 true,没有返回 false
title:Physics.Raycast 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22//声明射线
Ray ray = new Ray(Vector3.right, Vector3.forward);
if (Physics.Raycast(
ray,
1000,
1 << LayerMask.NameToLayer("Default"),
QueryTriggerInteraction.Ignore))
{
print("碰撞到了对象");
}
//不声明直接传参
if (Physics.Raycast(
Vector3.right,
Vector3.forward,
1000,
1 << LayerMask.NameToLayer("Default"),
QueryTriggerInteraction.Ignore))
{
print("碰撞到了对象");
}
获取相交的单个物体信息
**物体信息类 RaycastHit
**:射线投射命中
- 参数一: 射线(或直接传入射线起点和方向)
- 参数二:
RaycastHit
是结构体,是值类型。 unity 会通过out
关键字,在函数内部处理得到的碰撞数据并返回到该参数中 - 参数三: 距离
- 参数四: 检测指定层级(不填检测所有层)
- 参数五: 是否忽略触发器
- UseGlobal 使用全局设置
- Collide 检测触发器
- Ignore 忽略触发器
- 不填默认使用 UseGlobal
- 返回值: bool 当碰撞到对象时返回 true,没有返回 false
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//声明射线
Ray ray = new Ray(Vector3.right, Vector3.forward);
if (Physics.Raycast(
ray,
out RaycastHit hit,
1000,
1 << LayerMask.NameToLayer("Default"),
QueryTriggerInteraction.Ignore))
{
//碰撞器信息,得到了碰撞器就可以获取物体所有信息
print(hit.collider.gameObject.name);
//碰撞到的对象的位置
print(hit.transform.position);
//碰撞到对象离射线起点的距离
print(hit.distance);
//碰撞点,射线与物体相交的点
print(hit.point);
//碰撞点法线,射线与物体相交的点的法线
print(hit.normal);
//碰撞点uv坐标,射线与物体相交的点的uv坐标
print(hit.textureCoord);
//省略...
}
//不声明直接传参
if (Physics.Raycast(
Vector3.right,
Vector3.forward,
out RaycastHit hit,
1000,
1 << LayerMask.NameToLayer("Default"),
QueryTriggerInteraction.Ignore))
{...}
获取相交的多个物体
可以得到碰撞到的多个对象,如果没有就是容量为 0 的数组
- 参数一: 射线(或直接传入射线起点和方向)
- 参数二: 检测的最大距离,超出这个距离不检测
- 参数三: 检测指定层级 (不填检测所有层)
- 参数四: 是否忽略触发器
- UseGlobal 使用全局设置
- Collide 检测触发器
- Ignore 忽略触发器
- 不填默认使用 UseGlobal
- 返回值: bool 当碰撞到对象时返回 true,没有返回 false
1 | //声明射线 |
获取相交物体的数量
1 | RaycastHit[] hits = new RaycastHit[10]; |
四、协同程序
1 Unity 多线程
Unity 是支持多线程的,只是线程是无法调用 Unity 主线程的 API(不常用)
注意: Unity 中的多线程要记得关闭(即便停止运行,线程仍会执行)
子线程可以执行一些可能导致主线程卡顿的算法计算(寻路、网络等算法),将结果放入公共容器,主线程取出使用。相反,主线程也可以将数据放入公共容器,子线程取出使用。
1 | public class test : MonoBehaviour |
2 Unity 协程
协同程序(Coroutine)简称协程,继承 MonoBehavior 的类都可以开启协程函数
它是“假”的多线程,它不是多线程
主要作用:将代码分时执行,不卡主线程。简单理解,是把可能会让主线程卡顿的耗时的逻辑分时分步执行
主要使用场景
- 异步加载文件
- 异步下载文件
- 场景异步加载
- 批量创建时防止卡顿
协程和线程的区别:
- 子线程是独立的一个管道,和主线程并行执行
- 协程是在原线程之上开启,进行逻辑分时分步执行
协程开启后
- 组件和物体销毁,协程不执行
- 物体失活,协程不执行
- 脚本组件失活,协程执行
1 | private void Start() |
[[《CS Primer》#用 yield return 语法糖实现迭代器]]
1 | //1.下一帧执行 |
3 协程原理
协程可以分成两部分
- 协程函数本体
- 协程调度器
- 协程本体就是一个能够中间暂停返回的函数
- 协程调度器是 unity 内部实现的,会在对应的时机帮助我们继续执行协程函数
- Unity 只实现了协程调度部分
- 协程的本体本质上就是一个 cs 的迭代器方法
协程调度器
继承 MonoBehavior 后开启协程
相当于是把一个协程函数(迭代器)放入 Unity 的协程调度器中帮助我们管理
具体的 yield return 后面的规则也是 Unity 定义的一些规则
1 | //1. 协程函数本体 |
总结:你可以简化理解迭代器函数
- cs 看到迭代器函数和 yield return 语法糖就会把原本是一个的函数变成”几部分”
- 我们可以通过迭代器,从上到下遍历这“几部分”进行执行
- 就达到了将一个函数中的逻辑分时执行的目的
而协程调度器就是利用迭代器函数返回的内容来进行之后的处理
比如 unity 中的协程调度器
根据 yield return 返回的内容决定了下一次在何时继续执行迭代器函数中的“下一部分”
理论上来说我们可以利用迭代器函数的特点自己实现协程调度器来取代 unity 自带的调度器(一般自己不需要实现,唐老师课程作业中讲了具体做法)
4 应用
协程计时器
1 | private void Start() |
分时创建对象,防止批量处理卡顿
创建 100000 个 Cube,直接创建直接卡死,使用协程每帧生产 1000 个
1 | private void Update() |
五、资源动态加载
1 文件夹路径获取
- @ Assets 工程文件夹
1 | Application.dataPath //获取到Assets文件夹的路径 |
- @ Resources 资源文件夹
[!attention]
需要在 Assets 下手动创建名为 Resources 的文件夹
1 | //一般不获取,只能使用 Resources 相关 API 进行加载 |
作用:资源文件夹
需要通过 Resources 相关 API 动态加载的资源需要放在其中
该文件夹下所有文件都会被打包出去
打包时 unity 会对其压缩加密
该文件夹打包后只读,只能通过 Resources 相关 API 加载
在一个工程当中 Resources 文件夹可以有多个(子文件夹中也可以有),通过 API 加载时,它会自己去这些同名的 Resources 文件夹中找资源。打包时所有 Resources 文件夹打包在一起
@ StreamingAssets 流动资源文件夹
[!attention]
需要在 Assets 下手动创建名为 StreamingAssets 的文件夹
1 | Application.streamingAssetsPath |
作用:流文件夹
打包出去不会被压缩加密,可以任由我们摆布
移动平台只读,PC 平台可读可写
可以放入一些需要自定义动态加载的初始资源
@ persistentDataPath 持久数据文件夹
[!attention]
不需要自己创建
1 | Application.persistentDataPath |
作用:固定数据文件夹
所有平台都可读可写
一般用于放置动态下载或者动态创建的文件,游戏中创建或者获取的文件都放在其中
常用来保存玩家数据和热更新
@ Plugins 插件文件夹
[!attention]
需要在 Assets 下手动创建名为 Plugins 的文件夹
路径获取: 一般不获取
作用:插件文件夹
不同平台的插件相关文件放在其中,比如 ios 和 Android 平台
- @ Editor 编辑器文件夹
[!attention]
需要在 Assets 下手动创建名为 Editor 的文件夹
1 | //一般不获取 |
作用:编辑器文件夹
开发 unity 编辑器时,编辑器相关脚本放在该文件夹中
该文件夹中内容不会被打包出去
@ Standard Assets 默认资源文件夹
高版本 Unity 没有这个文件夹了。
作用:
默认资源文件夹
一般 unity 自带资源都放在这个文件夹下
代码和资源优先被编译
2 Resources 资源同步加载
- 通过代码动态加载 Resources 文件夹下指定路径资源
- 避免繁琐的拖拽操作
常用资源类型
- 预设体对象 GameObject
- 音效文件 AudioClip
- 文本文件 TextAsset
- 图片文件 Texture
- 其它类型 2D 图片、动画文件、材质文件等等
注意:
- 预设体对象加载需要实例化
- 其它资源加载一般直接用
加载文件资源
1 | public class test : MonoBehaviour |
加载同名文件
Resources. Load
加载同名资源时无法准确(比如两个同名但是拓展名后缀(文件类型)不一样的文件,该方法无法区分),可以使用其他方法:
1 | //填写第二个参数,指定类型 |
1 | Object[] objs = Resources.LoadAll("filename"); |
泛型方法(推荐!)
方便快捷,指定了类型
1 | TextAsset ta2 = Resources.Load<TextAsset>("Text/Test"); //指定TextAsset类型 |
3 Resources 资源异步加载
同步加载中,如果我们加载过大的资源可能会造成程序卡顿
卡顿的原因就是从硬盘上把数据读取到内存中是需要进行计算的,越大的资源耗时越长,就会造成掉帧卡顿
异步加载就是内部新开一个子线程进行资源加载(加载完后存入公共容器),不会造成主线程卡顿
[!attention]
异步加载不能马上得到加载的资源,至少要等一帧
方法一:完成事件监听异步加载
好处: 写法简单
坏处: 只能在资源加载结束后进行处理
“线性加载”
1 | public Texture texture; |
方法二:协程异步加载
好处: 可以在协程中处理复杂逻辑,比如同时加载多个资源,比如进度条更新
坏处: 写法稍麻烦
“并行加载”
1 | public Texture texture; |
4 Resources 资源卸载
Resources 重复加载资源会浪费内存吗 ?
- Resources 加载一次资源过后,该资源就一直存放在内存中作为缓存
- 第二次加载时发现缓存中存在该资源,会直接取出来进行使用,所以多次重复加载不会浪费内存
- 但是会浪费性熊 (每次加载都会去查找取出,始终伴随一些性能消耗)
卸载指定资源:Resources. UnloadAsset()
注意:
- 该方法不能释放 Gameobject 对象,因为它会用于实例化对象(即使是没有实例化的 Gameobject 对象也不能使用该方法卸载)
- 它只能用于一些不需要实例化的内容,比如图片和音效文本等等
- 一般情况下我们很少单独使用它
1
2
3
4
5
tex = Resources.Load<Texture>("filename");
Resources.UnloadAsset(tex); //卸载资源
tex = null;
卸载未使用的资源:Resources.UnloadUnusedAssets()
一般在过场景时和 GC 一起使用
1 | Resources.UnloadUnusedAssets(); |