Unity-基础知识
Unity-基础知识
常识
很多都是参数介绍,看的时候打开Unity对着面板看一眼
工程文件夹都是干什么的,以及需要版本管理的文件夹。初始化推送后,拷贝项目到新电脑并拉取即可同步。
.
├── Assets // 美术脚本主要工程文件,只要它不丢,其余文件删除后打开Unity会自动重建,加入Git
│ ├── ArtRes // 原始美术资源,可不加入Git!
│ ├── UsedArtRes // 从ArtRes中拖出使用资源避免直接修改原始资源
│ └── other_folder...
├── Library // 库文件夹PackageCache中的包为Unity自动下载,缺失时重新打开项目会自动恢复
├── Logs // 日志文件夹,记录特殊运行信息
├── Packages // 包配置信息,建议加入Git
├── ProjectSettings // 工程设置信息,删后重建会丢失自定义配置,必须加入Git
├── Temp
├── UserSettings // 用户设置,建议加入Git
├── AssetBundles
├── Assembly-CSharp-Editor.csproj
├── Assembly-CSharp.csproj
├── Client.sln
└── Client.sln.DotSettings.user
重要的几个面板
- Hierachy所有游戏中对象的层级面板
- Sence当前的游戏场景
- Game当前游戏画面
- Project项目中的所有资源
- Inspector某一个对象/资源的属性
- Window->Package Manager 管理安装的第三方包
- 给unity添加一些组件现在是在File->BuildSettings里面找到
Sence中常用快捷键
- alt+滚轮缩放
- alt长按可以围着视觉中心 转
- 鼠标右键+wasd移动(shift可以加速)
- 鼠标中键 原地不动360度拖动场景
- ctrl+D快速复制
- F2快速改名
- ctrl+shift+F快速对焦摄像机到编辑模式视窗
- 按住V可以选择顶点吸附,双击鼠标中键或F键可以定位场景内的物体
面板
Sence的工具栏中比较重要的几个
shading mode,默认shader渲染,可以切换网格/混合渲染,后面的是2D视图 声 光 特效 辅助线开关,摄像机调整。
Gizmos是用来设置Sence中不同类型的GameObject图标的, 一般这个工具栏的东西都不用特殊调整。
轴向以屏幕为参照物:
- 垂直屏幕向内为Z正方向
- 平行屏幕向右为X正方向
- 平行屏幕向上为Y正方向 操作物体工具栏的快捷键竟然是QWERTY(????
可以切换操作物体的轴是以自己为准还是以世界坐标为准,按单位移动需要使用世界坐标,按住ctrl按单位移动的精细一些。
使用Global的时候更改旋转座标始终不变看看是不是用错了Gizmo
使用pivot或者center,可以切换transform是以对象组的根对象为中心还是对象组的中心
眼睛和手是是否可以看见和是否可选中
Game界面⏯是逐帧播放,status是渲染详细信息
project的更多选项(三个点)可以切换界面显示方式,可以选择两列显示,Asset alt缩放到最小按照行显示
Inspector面板,可以设置在场景设置中的图标,Layer系统和Tag系统可以给对象分类,可以在更多中设置Debug显示更多信息方便调试
顶栏菜单额外嘱咐的
- Edit->Selection: alt+ctrl+1 编队(save selection 1) shift+ctrl+1 选中编队(load selection 1) 在mac上是command
- Edit->ProjectSetting设置保存到Asset同级的文件夹ProjectSetting
- Edit->Preferences
- Assets->import/export package
- GameObject->ctrl+shift+F把对象(大部分时候是摄像机)视角和当前的Sence视角重合
- Window->General上述几个窗口都在里面显示
- Window->Analyze->Profiler 这是性能监控器
对象关系
transform是最基本的位置信息。物体拿到手transform先归零好判断位置,子对象的transform是针对父对象来说的,会根据父对象变化而变化。
unity的基本单位是米,体积是基本单位就是立方米,质量的基本单位是kg 在 Unity 外部创建模型规范 - Unity 手册
Warning
游戏是数据的一种体现,利用程序把美术和数据组织起来
无论是美术还是自己拼模型,模型的正方向一定是Z轴的正方向,也就是垂直纸面向内。和开发者的目光齐平,缩放单位要注意!
骨骼-非必须,有动作的模型才需要
网格面片-必须,决定了模型的轮廓
贴图-必须,决定了模型的颜色效果
官方推荐使用fbx格式的模型文件其它格式虽然支持,但是不推荐。[.fbx .dae .3ds .dxf .obj]
导入完之后看看他有没有做好了预设体,别傻乎乎自己再去重复劳动
最好使用空物体作为总的父对象,容易控制缩放
美术给的资源最好不要直接改,拖一份复制到工作文件夹。
一般和模型搭配的武器等,会预留一个节点拖上去之后重置位置就应该是匹配好的
拼UI的时候,一般会给一张美术底图,垫在底下照着拼。
基础操作
Unity利用反射获取 场景中(Sence.unity) 的所有自定义的 GameObject和挂载其上的脚本,所有的特征都是GameObject +Component呈现出来的
场景的本质是记录了 GameObject和关联的信息存起来的文件,后缀是Sence.unity 把两个场景都拖到Hierachy中可以让场景叠加显示,一般只有互相copy的时候才这么干
public的属性能在inspector上编辑,加上[HideInspector]
就隐藏了;private的和protected不能被显示,加上[SerializeField]
也可以被显示
自定义的结构体和类对象使用[System.Serializable]
就可以被显示了。
已经挂到 GameObject修改了脚本的值对象上挂载的那个不会被更改,手动或者重挂,也可以运行过程中可以用Inspector->copy和paste脚本,但是一般不会这么做
departure的方法如果硬要用会报错,但是传第二个参数为false就可以继续用虽然不推荐
untiy支持的宏(可恶要变成宏孩儿了吗
📌Tip
其实直接拖对象有点破坏面向对象思想,尤其是多人合作依赖关系容易变成一坨,而且从外边拖进来的你还没法从代码直接点过去
代码Find之后再改效率不如拖高,因为不用查找(其实查找也查找不了几个最多在自己的子去查找)
在Editor绑定的好处是程序做完了,给个毛坯给策划,让他们自己去精装,把体力活交给策划,程序只负责实现功能就行了。
还有一些attributes不太重要的有个印象就行
- 为变量分组
[Header("分组说明")]
- 为变量添加鼠标悬停说明
[Tooltip("说明内容")]
- 让两个字段间出现一点间隔
[Space()]
- 数值变为使用滑动条范围设置
[Range(最小值, 最大值)]
- 显示框大一点,默认不写参数时显示3行,写参数则对应显示相应行数
[Multiline(4)]
- 也是显示框默认不写参数时超过3行显示滚动条;可以指定最少和最多显示行数,超过最大行数则显示滚动条
[TextArea(3, 4)]
- 右键变量的时候执行方法function(无返回值无参)
[ContextMenuItem("右键显示啥名", "function")]
- 为方法添加特性能够在Inspector中直接点更多去执行
[ContextMenu("测试函数")]
Perfab
Perfab的存储规则和Sence是一样的,C#脚本组件任何东西都一块保存。
在Inspector面板中可以把当前选中的预设体的更改保存到预制体或者放弃更改,重新拖进Assets中是覆盖。如果删除或者修改已有预设体,那所有的预设体都会被修改
在场景中直接unpack prefab然后编辑,再拖进Assets搞个新的。把未修改预设体拖回去是复制一个一模一样的预制体。如果预设体被删除但是场景中已经使用了,就会变红提示,不想要就直接unpack。
prefab的unpack和unpack cmoplete是选择是否解开子物体的预制体(如果子物体是prefab的话)
组件
Warning
在Unity中类名和文件名必须一致,因为是通过反射字符串去获取类和自定义组件的
自己写的脚本实际上也是组件(Component)继承了MonoBehaviuor的都是组件。 一个脚本可以挂在同一个对象多次,也就会创建多个脚本对象,可以脚本中开[DisallowMultipleComponent]
特性就不能一挂多了。其实本质是拖过去的时候通过反射获取文件的名字,然后根据名字字符串拿到对应类
MonoBehaviuor的继承链是MonoBehaviuor <- Behaviour <- Component
,继承Component的就是组件。当然可以继续继承
集成了MonoBehaviour的类只能挂载不能new,不能自定构造函数。 在Inspector->ExecutionOrder中其实可以规定执行顺序的,默认顺序是不确定的,Editor/Data/Resources/ScriptTemplates
可以修改默认脚本模板,这俩一般不用特意改。
生命周期
人眼舒适放松时候看电影可视帧数是24fps而动态交互类的一般是60fps,游戏卡顿的原因就是一帧内的计算量过大没法处理完所有的游戏逻辑。
生命周期函数(是按下面顺序执行的!!!!):
- Awake:首次被产生执行一次,再次启用就不会执行(如果生产对象是一开始inactivate的,那也不会执行)
- OnEnable: 依附的GameObject每次activate时调用,可以用来Fetch资源
- Start:从自己被创建出来后,第一次帧更新之前调用只会调用一次
- FixedUpdate: 固定时间(0.2)刷新一次,可在edit->project setting->time更改间隔
- Update: 每帧执行一次(注意性能
- Unity在这两个生命周期之间进行动画系统的更新
- LateUptedate: 每帧后执行一次
- 所以为了渲染效果一般在这里更新摄像机的位移
- OnDisalbe: 依附的GameObject每次禁用activate事件执行,在OnDestory时也会执行一次
- OnDestory:销毁的时候执行
生命周期函数是支持继承和多态的, 子类继承类之后进行override就可以。
但是生命周期函数却不是从基类中override过来的
如果生命周期无法满足需求那就需要使用任务调度,时间任务调度函数Invoke
InvokeRepeating
CancelInvoke
(参数都简单易懂
生命周期
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TestLifeCycle : MonoBehaviour
{
void Awake() // 首次被产生或者从不可见变为可见的前(核心就是首次启用)执行一次,再次启用就不会执行,例如查找资源用
{
Debug.Log("I'm awake");
}
private void OnEnable() // 每次启用前的函数,可以用来Fetch资源
{
Debug.Log("I'm Ready!");
}
void Start() // GameObject加载完毕后所有的Awake执行完的第一帧执行一次
{
Debug.Log("I'm Start First Time!");
}
private float timedelta = 0.2f;
void FixedUpdate()
{
Debug.Log($"every {timedelta} second Update once");
} // Update is called once per frame
void Update()
{
Debug.Log("frame start");
}
void LateUpdate() // 每帧之后
{
Debug.Log("frame end");
}
void OnDisalbe()
{
Debug.Log("I'm Disabled");
}
void OnDestory()
{
Debug.Log("I'm Destory");
}
}
运行结果
[21:35:31] I'm awake
[21:35:31] I'm Ready!
[21:35:31] Object start successfully Complete
[21:35:44] every 0.2 second Update once
[21:35:44] frame start
[21:35:44] frame end
[21:35:40] be Invoked
[21:35:35] be Invoked Hello, World!
[21:35:42] Invoke is Canceled
获取组件
MonoBehaviuor内置的重要变量,它能获得什么属性去IDE里看,稍微列一下最常用的,不赘述。this不是依附的GameObject
Component中声明的(this.)gameObject
可以直接使用,这个对象就是脚本挂载的对象。transform是获取位置信息
如果要获得其他的GameObject挂载脚本的对象,声明一个对应类然后把对象拖过去就可以。获得GameObject上的其他的 Component有一系列方法,参考一下,最常用的是根据泛型获得
如何获取GameObject身上的组件
void Start()
{
print(gameObject.name);// 挂载GameObject的名字
print(this.transform.position);// 对象的位置
print(this.transform.eulerAngles);// 旋转欧拉角
print(this.transform.lossyScale);// 缩放
print(this.enabled);// 脚本是否被启用
// 获得其他组件
GTest g1 = this.GetComponent("GTest") as GTest; // 通过名字获得
GTest g2 = this.GetComponent(typeof(GTest)) as GTest;// 通过类型获得
GTest g3 = this.GetComponent<GTest>(); // ⭐️通过泛型获得
GTest[] g4 = this.GetComponents<GTest>(); // 获得多个,但是无法确定是哪一个
GTest[] g5 = GetComponentsInChildren<GTest>(true); // 递归查找所有子对象包括自己的组件,参数代表要不要查找未激活的对象的
GTest[] g6 = GetComponentsInParent<GTest>(true); // 递归查找所有父对象包括自己的组件,参数代表要不要查找未激活的对象的
GTest gt;
bool exists = TryGetComponent<GTest>(out gt); // 尝试获得,返回值是bool
}
MonoBehaviour内置属性
⚡️Danger
变量的构造使用
在MonoBehaviour的子类中,变量声明是早于所有的生命周期函数的,所以基本不会在变量声明里面赋值引用类型,因为有可能使用未声明的变量。
所以赋值都是放到声明周期函数中。
Unity中的脚本加载顺序是随机的,同时在场景上挂载两个你不能控制哪个先运行,所以如果有变量的依赖可以使用事件通知或者协程异步的方式去加载。
GameObject
GameObject的一些静态方法。
可以直接申请一个对象然后拖进去(最常用的),或者使用下面的方法查找
常用的属性和查找对象是找不到incativate的对象的,如果场景中多个满足条件的对象,则无法确定是哪个
动态创建对象,先拖过去获取 GameObject然后使用GameObject.Instantiate(obj)
初始化新的,名字会带(Clone)GameObject.Destory(obj)
可以动态删除对象甚至可以删除附加的脚本,实际上是下一帧移除的,立即从内存中移除需要使用GameObject.DestroyImmediate(mycube);
这俩函数实际上有很多重载方法。
默认切换场景的时候之前的都被移除了,如果不想某对象换场景被移除,使用GameObject.DontDestroyOnLoad(this.gameObject);
最常用的就是传自己
GameObject的操作
public class L4Script : MonoBehaviour
{
private GameObject mycube;
public GameObject find;
public GameObject[] finds;
public GameObject dynamicObj;
public GameObject dynamicObjClone;
void Start()
{
Debug.Log(gameObject.name);
Debug.Log(gameObject.isStatic);
Debug.Log(gameObject.activeSelf);
Debug.Log(gameObject.layer); // 这返回的其实是个整数
Debug.Log(gameObject.tag);
Debug.Log(gameObject.transform.position);
mycube=GameObject.CreatePrimitive(PrimitiveType.Cube); // 和右键点击创建一样,也能创建其他类型
mycube.name = "xx";
mycube.tag = "Monster";
// 查找 是找不到失活对象,如果场景中多个满足条件的对象,则无法确定是哪个
find = GameObject.Find("xx");// 需要遍历所有对象,运行效率比较低 一般不这么找
find = transform.Find("xy"); // 只寻找自己的子对象,在子对象和坐标那有详细的
find = GameObject.FindWithTag("Monster"); // 一样FindGameObjectWithTag
finds = GameObject.FindGameObjectsWithTag("Monsters"); // 只有通过tag查找能找到多个对象
L4Script l4 = FindObjectOfType<L4Script>(); // UnityEngine.Object的查找更费劲,不仅遍历对象还遍历脚本
dynamicObjClone=GameObject.Instantiate(dynamicObj); // 获取到对象然后初始化的方法
GameObject.Destroy(dynamicObjClone,5); // 五秒后删除
GameObject.Destroy(this);// 甚至可以删除附加的脚本
GameObject.DestroyImmediate(mycube);// 立即从内存中移除,更可能会卡顿
GameObject.DontDestroyOnLoad(this.gameObject);// 最常用的就是传自己
}
}
当你new GameObject的时候实际上就创建了一个 空物体 甚至创建之后可以挂脚本。甚至脚本中套娃创建都能执行
动态的为对象添加组件(脚本也是组件),拿到组件就可以更改组件的属性,从而控制行为。
动态创建GameObject
void Start()
{
GameObject ng1 = new GameObject("我创建的新的空对象"); // 创建了一个新的 空对象
ng1.name = "我创建的新的空对象1";
GameObject ng2 = new GameObject("我创建的新的空对象",typeof(GTest));
GTest gt1=ng1.AddComponent(typeof(GTest)) as GTest; // 动态添加组件
GTest gt2=ng1.AddComponent<GTest>(); // 使用泛型动态添加组件更常用
Debug.Log("success init");
if (find.tag == "Monster")// 标签比较
{
bool TagEqual = find.CompareTag("Monster"); // 和上面的条件一样
print($"Tag is Monster{TagEqual}");
}
find.SetActive(false); // 激活函数不多说
find.SetActive(true);
}
📄Info
不常用的报个菜名
this.gameObject.SendMessage("TestFun");
通过反射去动态调用这个 GameObject脚本上的函数this.gameobject.BroadcastMessage("func");
广播行为 让自己和自己的子对象执行this.gameobject.SendMessageupwards("func");
向父对象和自己发送消息并执行
tag和layer
每个GameObject都有这两个属性,可以在Inspector面板进行编辑,可以给Layer起名字。
obj.layer = LayerMask.NameToLayer("LayerA"); //给物体设置层
int l=LayerMask.NameToLayer("UI"); // LayerToName
Physics.OverlapBox(Vector3.zero,Vector3.one, Quaternion.AngleAxis(45,Vector3.up),
1<<LayerMask.NameToLayer("UI") | 1<<LayerMask.NameToLayer("Default") // 检测UI和Default层0000 0000 0000 0010 0001
);
注意此处数字层的含义,这个int代表的是此层的二进制哪一位是1,unity一共32层,如果你要检测第五层和第1层,则需要如上述代码一样,将层数二进制左移对应数,然后两层进行或操作,这样用一个二进制就可以表示你想检测的所有层,unity内部会进行与操作再去检测
SortingLayers相当于PS中层的概念,每个Object属于哪个层,最下面的从层最靠前
Time和Mathf
在编辑模式下,是不限帧率的。Time用于游戏中参与位移、记时、时间暂停等,Mathf和Random都是Unity命名空间下的不是C#自带的Random.Range(start,end)
传浮点返浮点,传整数返整数;整数有头没尾,浮点有头有尾。
Time的基本API用法
void Update()
{
Time.timeScale = 1.0f; // 缩放倍数0的时候停止
print(Time.deltaTime); // 最近两帧间隔多长时间,稳定应该是 (1/帧率)S,主要是用来计算位移的 路程=时间*速度
print(Time.unscaledDeltaTime);// 不受timeScale影响的帧间隔时间,如果希望游戏暂停了还跑的东西
print(Time.fixedTime);// 两个FixedUpdate的间隔时间
print(Time.fixedDeltaTime); // 物理帧间隔,受scale影响
print(Time.fixedUnscaledDeltaTime); // 物理帧间隔,不受scale影响
print(Time.frameCount);// 游戏到现在跑了多少帧了
print(Time.time);// 游戏开始到现在过了多少时间
// Random
Random.Range(start,end)
}
Mathf
Math是System中提供的数学类,而Mathf是unity提供的结构体,二次封装且多了一些游戏常用的,用他就行。
取整数 直接类型转换 和 Mathf.FloorToInt(9.6f)
是向下取整,而CellToInt
是向上取整。
数学库Mathf
Mathf.PI
Mathf.Abs(-10) // 取绝对值
Mathf.CeilToInt(1.00001f)//向上取整 Mathf.FloorToInt(9.6f)//向下取整
// 钳制函数如果不超过区间就返回传入的数字,否则返回靠近的区间端点
Mathf.Clamp(10, 11, 20) Mathf.Max(1, 2)
Mathf.Min(1.1f, 0.4f)
Mathf.Pow(4, 2)// 一个数的n次幂
Mathf.RoundToInt(1.3f) // 四舍五入
Mathf.Sqrt(4) // 返回一个数的平方根
Mathf.IsPowerOfTwo(4)// 判断一个数是否是2的n次方
Mathf.Sign(0)// 判断正负数
// 每帧改变start的值按照极限逼近lim
start = Mathf.Lerp(start, 10, Time.deltaTime);
// 结果会线性跟随
time += Time.deltaTime;
result = Mathf.Lerp(start, 10, time);
Lerp插值公式为result = start + (end - start) * t;
其中t为速度系数,常用的两个调用方式取值如下两曲线,lim(每帧改变start)和线性(每帧改变t)
弧度rad即是PI/180
,使用静态变量Rad2Deg = 57.29578f;
,Deg2Rad = 0.017453292f;
一乘即可和角度互相转换;Mathf.Sin(r)
参数和返回值都是rad类型,可以传30*Mathf.Deg2Rad
Transform
Vector3是Unity内置的结构体,他不是类!默认值(0f,0f,0f),初始化的时候传xy则z自动为0,默认的加减乘除就是对应坐标加减乘除,重载了哪些运算符点进去看。
Vector
Vector是Unity中的结构体,可用的有Vector2 Vector3 Vector4。
向量已经进行运算符的重载,使用B-A可以获得A指向B的向量,取反会三个方向都加负号,也就是指向反方向。
使用V.magnitude
直接获得向量的模长和Vector3.Distance(Vector3.zero,V)
一样,也就是三维空间中的距离Mathf.Sqrt(x^2+y^2+z^3)
向量除自己的模长可以归一化为单位向量,使用V.normalized
获得,和V/V.magnitude
一样
向量演示
Vector3.zero // (0, 0, 0) 零向量
Vector3.right // (1, 0, 0)
Vector3.left // (-1, 0, 0)
Vector3.forward // (0, 0, 1)这是Unity的正方向
Vector3.back // (0, 0, -1)
Vector3.up // (0, 1, 0)
Vector3.down // (0, -1, 0)
Vector3.Distance(v1,v2)// 计算两点之间的空间距离,和V.magnitude一样
V.normalized==V/V.magnitude // 归一化 true
float result=Vector3.Dot(A,B); // A•B点乘的用法
Vector3 V=Vector3.Cross(A,B) // A*B叉乘的用法
点乘 获得的是对应坐标相乘再相加的一个标量,A•B的几何意义是B在A上的投影长度,因为AB是向量,所以可以用来判断AB的夹角>0锐角=0直角<0钝角。
可以大致判断物体的方位,物体面朝向是A然后另一个物体的坐标是B,此时使用A•B就可以大致判断方位
如果要做侦测是否在角度范围内,注意到Cosβ=A•B
使用反三角函数直接算出来,但是其实可以调用Vector3.Angle(A,B)
其实也使用点乘获得的
叉乘 每个纬度原来的数值不会参与运算即X=YaZb−ZaYb; Y=ZaXb−XaZb; Z=XaYb−YaXb;
使用Vector3 V=Vector3.Cross(A,B)
,新的向量V是AB的法向量,也是AB平面的法向量,若A在B的右侧 则法向量是垂直向上的反之则向下
假设向量 A 和 B 在 XZ 平面内,如果算出来Y>0则说明向量 B 在向量 A 的右侧反之在左侧,那就可以结合点乘判断物体距离角度和左右侧。
Lerp 插值运算,对两个坐标点进行插值运算,和Mathf的Lerp一样,只不过是三维的。
Warning
注意线性插值瞬移问题
线性插值可以用来做一些摄像机的跟随移动这样的东西。
当线性插值t累加>=1的时候,公式可以直接抵消,result=start+(end-start)*1 = end+O(t),如果是向量Lerp则两个物体会直接重合起来没法分开了。
可以设置一些条件,让t和开始位置重置一下避免这个问题。
球形插值 Vector3.Slerp(A.position,B.position,Time.deltaTime);
不常用,可以让物体A呈弧线移动到位置B,比如模拟东升西落。
transform
transform.position
在是世界坐标的位置,而transform.localPosition
是相对于父对象的坐标,也就是面板上的。虽然可以单独调用,修改的时候只能通过position属性不能单独改某个维度的坐标
注意transform.forward/up/right
这三个是 GameObject自身的正方向,使用Vector3.forward/up/right
是世界坐标正方向。使用transform.Translate()
封装好的API移动
相对世界坐标角度transform.eulerAngles
和相对父对象的角度(面板上是这个)transform.localEulerAngles
角度的修改和坐标的修改差不多transform.lossyScale
相对于世界坐标的缩放,不能修改(那为什么要提供?transform.localScale
自己的缩放 没有提供API,只能自己慢慢每帧写transform.LookAt(Vector3.zero)
一直看向某个点,传进某个 GameObject就会一直盯着看,重载了的也可以传position。
📝Note
使用eulerAngles属性获得的始终是0-360度的角度,但是面板上是可以出现负数的,需要自己写一些逻辑转换角度
自身方向的偏转角是怎么算出来的? #todo
Transform的基本API用法
void Update()
{
// 每帧 移动 1/帧率*(0,0,1) 也就是每秒1单位(米)
transform.position += transform.forward * (Time.deltaTime * 1f);
// transform封装好的和上面一样,一般用它位移,参数控制相对于世界坐标还是自身坐标
// 注意这里就有排列组合了,以[自己or世界]坐标中心,偏移[自己or世界]朝向运动
transform.Translate(transform.up * (Time.deltaTime * 1f),Space.Self);
// 1. 相对于世界坐标系的z轴移动,始终是朝世界坐标系的z轴正方向移动
transform.Translate(Vector3.forward * (1 * Time.deltaTime), Space.World);
// 2. 相对于世界坐标的自己面朝向动,transform.forward会先转换为等效世界坐标系下的方向向量,然后始终朝自己的面朝向移动
transform.Translate(transform.forward * (1 * Time.deltaTime), Space.World);
// 3. 相对于自己的坐标系下的自己的面朝向量移动(一定不会这样让物体移动,因为Z轴偏转被算了两次
transform.Translate(transform.forward * (1 * Time.deltaTime), Space.Self);
// 4. 相对于自己的坐标系下的z轴正方向移动,始终朝自己的面朝向移动
transform.Translate(Vector3.forward * (1 * Time.deltaTime), Space.Self);
print(transform.eulerAngles);// 相对世界坐标角度
print(transform.localEulerAngles);// 相对父对象的角度,面板上是这个
transform.Rotate(new Vector3(0,10,0),Space.Self); // 绕自己坐标的Y轴转
transform.Rotate(new Vector3(0,10,0),Space.World); // 绕世界坐标的Y轴转
transform.Rotate(new Vector3(0,10,0),10*Time.deltaTime,Space.Self); // 重载 绕着哪个轴,转多少度,相对哪个坐标
transform.RotateAround(new Vector3(0,0,0),new Vector3(1,1,1),100*Time.deltaTime);// 相对于哪个点,哪个轴,旋转的角度
}
Quration
相比于欧拉角可以提供Lerp。
空间中的角旋转都可以分解为绕XYZ三个互相垂直的三个旋转角组成的序列。最常用的旋转序列约定就是heading-pitch-bank(Y-X-Z)约定,transform.eulerAngles
欧拉角的缺点是同一旋转的方式不唯一
万向节死锁
(万向锁)。
在unity中将物体的X轴调到90度会出现此现象,此时转动YZ都是绕Z转。
Warning
Gimbal lock问题
理解此问题的关键在于了解欧拉角旋转时的层级关系,当规定欧拉(2,1,3)时,其旋转层级关系可简单表述为y->x->z,即前者为后者的父层级。父层级的旋转会带动所有子层级旋转,而子层级的旋转不影响父层级的状态。
当x轴旋转时, 有概率使得z轴所在环与y轴所在环共面 ,此时即发生万向节死锁问题,导致z轴功能与y轴功能基本等效,即z轴失效。
此时若要旋转到需要的位置必须同时旋转3个轴,但这种旋转过程在实际空间中轨迹为弧线,不满足实际工程应用(比如摄像机转动会在球面上画个S弧而不是直线,运镜就很怪)
此时我们要用四元数解决这个问题,四元数包含一个标量和一个3D向量[w,(x,y,z)]
代表了3D空间中的一个旋转量,在3D空间中,任意旋转都可以表示绕着某个轴v旋转一个角度w(轴-角 对)得到,因为只有一个轴所以避免了轴面重合
对于给定旋转,假设为绕着 N轴旋转 β 度,N轴为(x,y,z),那可以构成四元数 Q=[cos(β/2),sin(β/2)N]
具体展开 Q=[cos(β/2),sin(β/2)x,sin(β/2)y,sin(β/2)z]
,第一个元素为四元数的实部,后面三个为虚部。
两个四元数相乘代表旋转四元数,四元数把角空间转换到180 ~ -180
此时位置的表示是唯一的,解决了 同一旋转表示不唯一 的问题。
单位四元数即[1,(0,0,0)] [-1,(0,0,0)]
角度为0或者360度,没有旋转量的,使用Quaternion.identity
获得,初始化对象的时候可以传入赋值
四元数同样提供了插值运算,即角度转向过程的插值,Quaternion.Lerp
和Quaternion.Slerp
。Lerp更快 但是如果旋转范围较大效果较差,建议首选Slerp进行插值运算。
注意LookRotation的运算,当A朝向B的时候,AB向量为轴,A可以绕此轴旋转自身,此时使用upwards
规定Y轴也就是上的方向,三个角度就都就确定了,默认upwards是世界坐标的上方向
四元数相关操作
void Start()
{
Vector3 fake_part=Mathf.Sin((60 / 2)*Mathf.Deg2Rad) * Vector3.right;
Quaternion q1 = new Quaternion(fake_part.x,fake_part.y,fake_part.z, (60 / 2)*Mathf.Deg2Rad);
Quaternion q2 = Quaternion.AngleAxis(60, Vector3.right); // 这种初始化常用,上面的不常用
Quaternion q3 = Quaternion.Euler(new Vector3(30,30,90)); // 欧拉角 转 四元数
q3.eulerAngles // 获得 四元数 的 欧拉角
Quaternion.identity; // 单位四元数
}
void Update()
{
transform.rotation*=Quaternion.AngleAxis(1, Vector3.up); // 四元数相乘,绕世界的Y轴转1度,每帧转
// Lerp和前面几个Lerp参数一样。
A.transform.rotation = Quaternion.Lerp(A.transform.rotation,target.transform.rotation,Time.deltaTime*0.3f);
B.transform.rotation = Quaternion.Slerp(B.transform.rotation,target.transform.rotation,Time.deltaTime*0.3f);
// 传入自己 A指向B的向量 可以计算出 A面朝B 需要的四元数,可以做到lookAt的效果,其实LookAt就是通过四元数做的。
A.transform.rotation=Quaternion.LookRotation(B.transform.position - A.transform.position, upwards:Vector3.up);
}
两个四元数相乘的结果,相当于绕物体自己的坐标系旋转角叠加后的四元数, 注意四元数运算永远是使用物体本地坐标系进行旋转的
使用向量乘四元数的结果,相当于把此向量旋转对应四元数的旋转量后的向量, 注意此运算不满足交换律四元数必须在前
子对象和坐标
当把子对象拖到世界空间的时候,位置信息是加法,而缩放信息是乘法的;如果你再拖回去,那么就倒过来算一次(拖到GameObject下同理)。
坐标转换的时时候,会受到缩放影响,应该是除了scale
也可以用代码完成拖动
void Start()
{
print(transform.parent.name);// 获取父对象
transform.parent = null; // 脱离父对象
GameObject F2 = GameObject.Find("F2");
transform.parent = F2.transform; // 绑定父对象
GameObject F3 = GameObject.Find("F3");
// 如果是False,那么会不经过计算而是直接绑定对象关系然后把原来的transform赋值过去
transform.SetParent(F3.transform,false);
transform.DetachChildren(); // 把自己下一层的物体都脱离,非递归
transform.Find("S2"); // transform下的Find是查找子对象,且失活的也能找到,非递归
print(transform.childCount);// 获得自己的子对象数量
transform.GetChild(0); // 通过下标索引获得子对象,这样就可以便利了
transform.IsChildOf(F2.transform); // 判断是不是传入对象子对象
transform.GetSiblingIndex(); // 获得自己的子对象编号,当然也有Set,Set传入溢出的时候就成为最后一个
transform.SetAsFirstSibling();// 设置为第一个,设置为最后一个
//世界转本地 #转换坐标系,可以大概判断相对位置
print(transform.InverseTransformPoint(Vector3.forward)); // 点转换
print(transform.InverseTransformDirection(Vector3.forward));// 单位向量转换 不受缩放影响
print(transform.InverseTransformVector(Vector3.forward));// 单位向量转换 这个会受缩放影响
// 本地转世界
print(transform.TransformPoint(Vector3.forward)); // ⭐️点转换(在单位的某方向放效果,可以直接转为世界然后创建)
print(transform.TransformDirection(Vector3.forward));// 单位向量转换 不受缩放影响
print(transform.TransformVector(Vector3.forward));// 单位向量转换 这个会受缩放影响
}
核心组件
InuptSystem和Screen
Input类是内置的。键盘鼠标:屏幕的原点是左下角,所有的输入都是在Input
类中管理的,一般用键盘的枚举在KeyCode
,用Input.anyKeyDown
和 Input.inputString
捕获输入设置快捷键可以这么干 在 输入管理器 ProjectSettings-> InputManager设置默认轴输入Input.GetAxis
,Unity中使用轴向来代表输入的方向,键鼠有四个常用的,不用我们手动处理按键和移动距离直接返回Float,移动越快值越大
这个方法返回的值是会缓冲逐渐加速 在 -101 之间,如果使用Input.GetAxisRaw
可以获得不带加速度的
注意怎么处理组合键,如果组合键非常多,可以先用变量都捕获,然后下面自己加一堆逻辑判断。什么多点触控屏幕陀螺仪手柄,用到了再说
Input的基本API用法
Input.mousePosition; // 获得鼠标在屏幕上的位置
Input.GetMouseButtonDown(0) // 0左键1右键
Input.GetMouseButtonUp(0)
Input.GetMouseButton(0) // 持续按下
if (Input.mouseScrollDelta!=Vector2.zero){ // 鼠标滚动返回的是Vector2,正数向上滚反之可推
print(Input.mouseScrollDelta);// 滚动了才打印, 而且只变动Y值
}
Input.GetKeyDown(KeyCode.X) // 获得某个键的按下事件,只会触发一次
Input.GetKeyDown("b") // 也可以使用函数调用而不是枚举,大小写敏感,只接受小写字符串
Input.GetKeyUp(KeyCode.X) // 获得某个键的弹起事件
Input.GetKey(KeyCode.D) // 长按理论上是一帧一次,但是会帧率太高的话Unity中会优化,让触发频率降低
/* 处理组合键 */
bool isCtrlPressed = Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl);
bool isCPressed = Input.GetKey(KeyCode.C);
if (isCtrlPressed && isCPressed)Debug.Log("Ctrl + C is pressed.");
/* 下面这俩搭配可以记录快捷键 */
Input.anyKeyDown // bool 是否有任意键按下
Input.anyKey // bool 是否有任意键长按
if (Input.anyKeyDown) print($"Pressed {Input.inputString}"); // 记录下捕获的,他只会捕获上一个
string[] strs = Input.GetJoystickNames(); // 获得所有手柄的名字
/* 使用默认轴输入 */
print(Input.GetAxis("Horizontal")); // 默认AD
print(Input.GetAxis("Vertical")); // 默认WS
print(Input.GetAxis("Mouse X")); // 鼠标水平
print(Input.GetAxis("Mouse Y")); // 鼠标竖直
官方包有新的InputSystem,2019后都推荐。
Screen属性很简单就是宽高刷新率,注意设备分辨率和窗口的区别,自动旋转屏幕和运行时全屏也可以设置但是不太重要。
Screen的基本API用法
void Start()
{
int h=Screen.width; // 当前窗口宽高
int w=Screen.height; // 当前窗口宽高
print($"运行窗口 高{h} 宽{w}");
Resolution r = Screen.currentResolution; // 运行设备的宽高,如果是在编辑器里面是开发机器的屏幕
print($"执行程序的机器 高{r.height} 宽{r.width} 分辨率{r}");
Screen.sleepTimeout = SleepTimeout.NeverSleep; // 运行着的时候设备屏幕不休眠
Screen.SetResolution(1920, 1080, false); // 一般是在非移动设备上才设置分辨率
Screen.fullScreen = true;// 运行时全屏
Screen.fullScreenMode = FullScreenMode.Windowed;// 独占全屏 全屏窗口 最大化窗口 窗口模式 去枚举里面看
}
📝Note
坐标系汇总
世界坐标系-世界原点为中心
物体坐标系-建模时候确定,相对父对象的坐标
屏幕坐标系-使用Screen获得宽高原点在左下角
视口坐标系-和将屏幕坐标系百分比化,xy最大值都是1,对角为(1,1)
视口坐标和场景坐标转换API也在Camera中Camera.main.WorldToViewportPoint()
Camera和Light
Camera可以设置的属性,非核心的随便看看就行了
- ClearFlags Skybox3D游戏常用;Soilder Color 2D游戏常用;Depth Only叠加渲染;最后是覆盖渲染模式不常用
- CullingMask 可以选择哪些Layer会被此摄像机渲染出来(想一下tag和layer系统
- Projection 透视模式或者正交模式
- Perspective 透视模式
- FOV Axis 视场角轴一般不改就用竖直方向就行
- Field of view 视口大小
- Physical Camera可以模拟真实世界中的摄像机焦距传感器尺寸透镜移位等,不懂摄影不改他
- Orthographic 正交摄像机(一般用于2D游戏制作)
- Size 摄制范围
- Perspective 透视模式
- ClippingPlanes 摄像机梯形盒的厚度
- Depth 多摄像机渲染的顺序,当多个摄像机渲染多帧,深度越深越早被叠加,会被后面摄像机的帧盖住
- 此时ClearFlags如果选择了Depth Only,那么此摄像机就只渲染选定层数的物体,然后按顺序叠加上去
- 如果想要使用多个摄像机,往往上层的摄像机会使用此模式。(UI一般会单独使用一个摄像机去渲染。这个很重要
- TargetTexture 渲染纹理,把摄像机输出的画面输出到一张图上,可以用来制作预览和小地图
- 创建RenderTexture选中即可使用
- Occlusion Culling 默认勾选被遮挡的物体不渲染省性能
- ViewportRect 不太重要可以调出类似于双人成行的效果
- TargetDisplay 不太重要可以输出到多个屏幕
📌Tip
摄像机拖影
在 Depth Only
模式下,Unity 不会清除颜色缓冲区(Color Buffer),只会清除深度缓冲区(Depth Buffer)
这种模式通常用于多摄像机叠加渲染(如背景摄像机和UI摄像机分离)时,避免重复绘制背景
如果主摄像机或其他摄像机没有正确清理颜色缓冲区,可能会导致上一帧的画面残留,从而产生拖影
如果你有多个摄像机,请确保至少有一个摄像机(通常是主摄像机)的 Clear Flags
设置为 Skybox
或 Solid Color
,以清理颜色缓冲区。
从代码中获取摄像机的场景中Camera的Tag为MainCamera的摄像机可以直接通过静态方法Camera.main获取,如果有多个(一般只有一个)随机获得一个
Inspector中的属性都可以用对象获得,摄像机提供的委托前妻不常用
注意 屏幕坐标<->世界坐标 转换Z轴值的问题
摄像机的的常用API用法
void Start()
{
print(Camera.main.transform.position);
foreach (Camera c in Camera.allCameras) print(c);// 获得场景所有摄像机的数组
print(Camera.allCamerasCount); // 获得摄像机的计数
// 转换之后返回Vector3, xy就是屏幕坐标,z就是物体离屏幕远近
Camera.main.WorldToScreenPoint(transform.position);
// 屏幕转世界
Vector3 mousePos = Input.mousePosition;
// 如果不改变这个值,那么屏幕上的值是没有Z的,默认就是0
mousePos.z = 10; // 所以这里要改成10,这里的Z就是 距离摄像机横切面是多远
// 这样才不会一直是一个点,如果Z是0那么xy在三维空间中转换也是一直在一个点上
Camera.main.ScreenToWorldPoint(mousePos);
// 在特殊的时刻调用的委托
Camera.onPreCull += Func; // 比如这个就是 剔除遮挡 时候的委托
Camera.onPreRender += Func;
Camera.onPostRender += Func;
}
Light相关,一眼能看出来的和不重要的属性就不放上去了。代码改改光晕强度什么的,照着面板改吧,其实提供的API不多
可以用点光源挑动和Holo大小模拟蜡烛,用方向光模拟白天黑夜轮换等
- Type 光源类型
- Spot 聚光灯
- Range 发光范围距离
- Spot Angle 光锥角度
- Directional 方向光(环境光)
- Point 点光源
- Area 面光源 只有在烘焙模式下有用不管他
- Spot 聚光灯
- Mode 光源模式
- Realtime 实时光源 每帧实时计算,效果好,性能消耗大
- Baked 烘焙光源 事先计算好,无法动态变化
- Mixed 混合光源 预先计算+实时运算
- RealtimeShadows 实时阴影
- Cookie 灯罩,用灰度图叠加出来光的形状,类似于灰度遮罩那种
- Cookie Size 灯罩大小
- Flare 耀斑,镜头耀斑,动漫开场里太阳那种
- 注意必须给Camera添加FlareLayer组件 才能在Game中看到耀斑
- Draw Halo 球形光环开关
- Culling Mask 哪些Layer会受到此光源影响
光源环境的设置在 菜单Window->Rendering->Lighting里面
在SkyboxMaterial中可以设置默认的天空盒(其实就是6个面的美术资源),默认最亮的方向光代表太阳
也可以改Fog雾(吃性能) Flare耀斑 Holo光晕 效果等,接触不到的时候暂时不用看
烘焙
3D Collider和Rigidbody
碰撞产生的条件: 两个物体都有Collider且至少一个有Rigidbody 有了刚体Rigidbody物体才会受到力的作用
检测不到的常见原因: 不在同一Layer,trigger和碰撞没对好,Body Type: Kinematic
脚本接管了
- 质量Mess越大越不容易被别的力推动(惯性大)。有了Rigibody才能受重力UseGravity影响,空气阻力Drag参数才有用。互相碰到的时候会产生扭矩作用力,随着飞行被AngularDrag扭矩阻力衰减,让物体旋转着飞出去。
- IsKinematic是否直接用代码接管物理特性
- 当物理帧更新间隔太长的时候,可以使用插值运算Interpolate让物体的物理现象更平滑
- 菜单Edit->PorjectSettings->Time->FixedTimestep每隔多少秒进行一次物理计算,也就是FixUpdate的间隔。
- Interpolate 根据前一帧的变换来平滑变换
- Extrapolate 根据下一帧的估计变换来平滑变换
- CollisionDetection碰撞检测频率算法 性能消耗依次增高,相应也更准确
- Discrete默认频率较低,有时候速度比较快可能没检测到,就会穿模
- Continuous连续检测
- Continuous Speculative连续推测检
- Continuous Dynamic连续动态检测
- Constraints可以冻结某个轴的旋转或坐标,全冻结就失去了物理特性,比如冻结Y轴的位移可以避免对象被弹飞
使用不同检测算法的物体 互相碰撞会用什么算法 | 无刚体碰撞盒 | Discrete (离散检测) | Continuous (连续检测) | Continuous Dynamic (连续动态检测) | Continuous Speculative (连续推测检测) |
---|---|---|---|---|---|
无刚体碰撞盒 | 不检测碰撞 | Discrete | Continuous | Continuous | Continuous Speculative |
Discrete | Discrete | Discrete | Discrete | Discrete | Continuous Speculative |
Continuous | Continuous | Discrete | Discrete | Continuous | Continuous Speculative |
Continuous Dynamic | Continuous | Discrete | Continuous | Continuous | Continuous Speculative |
Continuous Speculative | Continuous Speculative | Continuous Speculative | Continuous Speculative | Continuous Speculative | Continuous Speculative |
Collider是描述物体体积的,有了Collider物体才有体积不会穿模。IsTrigger勾选会只有碰撞检测,但是没有物理效果(比如剑和魔法穿过人物)。
Rigidbody直接加在父对象上,会把子对象都包括进来,Collider也是,父对象的碰撞检测会使用所有子对象累积起来的形状参与检测
网格碰撞器开启刚体必须开启Convex才能参与刚体计算。
在代码中检测
碰撞和Trigger属于特殊的生命周期函数,在FixedUpdate之后固定调用,调用循环间隔和Update不一样,也是通过反射调用。
只要挂载的对象能和别的对象产生碰撞和触发,那么对应的函数就会响应,有物理效果的是Collison没有的是Trigger,自己没挂别的挂了碰我也会触发
默认private,可以写成protected去子类中去重写,一般不会手动调用所以不要写成public。
参与计算的是Rigibody组件,父物体挂组件子物体挂了脚本触发不了的。
Rigibody添加力的单位是牛N,如果没阻力就会一直飞,想要一直动就一直加力和物理世界一样。注意爆炸力函数只对挂载的本脚本的物体起效,所以模拟爆炸就获得所有受影响的物体然后执行这个函数
使用组件ConstantForce组件,可以直接在面板上为物体施加力场,注意Unity有刚体休眠机制节约性能,如果发现刚体不好使了,叫醒一下
碰撞和力的基本API用法
public class Rigid : MonoBehaviour
{
private Rigidbody rigidBody;
void Start()
{
rigidBody = gameObject.GetComponent<Rigidbody>(); // 获得刚体组件
rigidBody.AddForce(Vector3.forward*10); // 添加相对于世界坐标系方向的力
rigidBody.AddRelativeForce(Vector3.forward*10); // 添加相对于自己标系方向的力
rigidBody.AddTorque(Vector3.forward*10);// 添加扭矩力
rigidBody.AddRelativeTorque(Vector3.forward*10);// 添加相对于自己标系方向的扭矩力
rigidBody.AddExplosionForce(100,Vector3.zero, 10); // 爆炸力 爆炸中心 爆炸半径
rigidBody.AddForce(Vector3.forward*10,ForceMode.Force); // 第二个参数是施加力的模式,力的函数都可以添加
rigidBody.velocity = Vector3.forward*10; // 很少用,可以直接改变速度
}
private void OnCollisionEnter(Collision collision) // 刚碰撞到
{
print(collision.collider); // 获得对方碰撞器的信息
print(collision.gameObject); // 获得对方游戏对象
print(collision.transform); // 获得对方的位置信息
print(collision.contactCount); // 获得有哪些点碰撞了
ContactPoint[] pos = collision.contacts; // 甚至获得所有碰撞了的点(不常用
}
private void OnCollisionExit(Collision collision){} // 结束碰撞的
private void OnCollisionStay(Collision collision){} // 持续碰撞过程中
/* Collider 对象属性和Collison差不太多,点进去看着属性拿 */
private void OnTriggerEnter(Collider other) {} // 刚开始接触
private void OnTriggerExit(Collider other){} // 结束接触,穿越过去的时候
private void OnTriggerStay(Collider other){} // 两者正在穿模的时候
void Update()
{
if(rigidBody.IsSleeping()) rigidBody.WakeUp(); // 当然也有rigidBody.Sleep()
}
// 让物体位移的方式,position赋值,Translate,AddForce,
}
#todo 力的模式和计算,父子物体的碰撞器组合实现CompositeCollider2D的效果 使用meshcollider太费性能了,所以可以加多个碰撞器拼个大概就行了,节省性能。
范围检测和射线检测
被检测的对象必须要有碰撞器。范围检测不会真正产生新碰撞器,而且检测是瞬时的。生成的触发器形状和默认能创建的那几个物体类型一样,最后会获得一个检测到的Coillder数组
Collider[] colliders = Physics.OverlapBox(Vector3.zero, Vector3.one, Quaternion.AngleAxis(45,Vector3.up), // 中心 大小 旋转角
1<<LayerMask.NameToLayer("UI"), // 检测哪一层
QueryTriggerInteraction.Ignore); // 是否忽略触发器
Collider[] colliders = Physics.OverlapSphere(transform.position+transform.forward+transform.up, 1, // 中心(自己的位置往上和前偏移1个单位),半径1
1<<LayerMask.NameToLayer("Monster")); //这里创建的是球形范围检测
// Physics.OverlapBoxNonAlloc多了个参数,直接先传入碰撞器数组存储结果,和ref一样 调用者负责初始化数组,方法只负责填充数据。
QueryTriggerInteraction
是个枚举类,默认值是使用Unity中的设置Edit->ProjectSettings->Physics->QueriesHitTriggers默认是勾上的,是否检测触发器Trigger。
射线检测 也是瞬时的。
注意Physics.RaycastAll
距离从远到近排序 排序的,最远的在index0
射线检测主要的API
void Start()
{
Ray r = new Ray(transform.position, transform.forward); // 从哪个点发出射线,以及射线的方向向量
print(r.origin);
print(r.direction);
Ray r2 = Camera.main.ScreenPointToRay(Input.mousePosition); // 从摄像机位置发条射线到鼠标的位置
}
void Update()
{
Ray r3 = new Ray(Vector3.zero, Vector3.forward); // 搞一条射线
bool isHit=Physics.Raycast(r3,1000,1<<LayerMask.NameToLayer("Monster")); // 只返回是不是碰到了 也可以传QueryTriggerInteraction
if (Physics.Raycast(Vector3.zero, Vector3.forward, 1 << LayerMask.NameToLayer("Monster")))
{
print($"Hited {isHit}");
}
if (Input.GetMouseButtonDown(0))
{
Debug.Log("常见 鼠标点击检测碰撞点并导航");
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit))
{
agent.SetDestination(hit.point);
agent.isStopped = false;
Debug.Log("目标点: " + hit.point);
}
}
RaycastHit rh;
if (Physics.Raycast(r3,out rh,1000,1 << LayerMask.NameToLayer("Monster")))
{
print(rh.collider.gameObject.name); // 这样就拿到了碰到了谁,只能拿到第一个碰到的
print(hit.point); // 获得碰撞点
print(rh.distance);// 获得离我多远
print(rh.normal); // 获得碰撞的法线向量
}
RaycastHit[] rhs = Physics.RaycastAll(r3, 1000, 1 << LayerMask.NameToLayer("Monster"));
if (rhs.Length!=0)
{
foreach (RaycastHit r in rhs)
{
print(r.collider.gameObject.name);
}
}
}
顺口把这俩API也看一眼,这俩是调试用的射线
Debug.DrawLine(A.position, B.position, Color.red, 0.1f);// 绘制从 A 到 B 的红色线段 显示时间0.1s
Debug.DrawRay(A.position, A.up * 5, Color.green, 0.1f); // 绘制从 A 出发的绿色射线,向 A 的上方方向 显示时间0.1s
PhysicMaterial和LineRenderer
物理材质和显示的模型材质不同,物理材质规定的是碰撞时物理特性。右键创建之后可以在Collider中为对象分配。
- Dynamic Friction 物体在上面运动时候的摩擦
- Static Friction 推动物体开始动的摩擦
- Bounciness 0不会反弹 1在反弹时不产生任何能量损失,但可能只会给模拟增加少量能量。
- Friction Combine 摩擦组合方式
- Average:对两个摩擦值求平均值
- Minimum:使用两个值中的最小值
- Maximum:使用两个值中的最大值
- Multiply:两个摩擦值相乘
- Bounce Combine 弹性组合方式与Friction Combine模式相同
使用LineRenderer组件 可用多个点划一些线段,点的多少可以增加减少,他会把点连起来绘制线段,每个对象身上只能有一个LineRenderer
- Loop 是否首尾点自动相连
- Positions调整线段粗细曲线
- Corner Vertices (角顶点的圆角)绘制角时使用了多少额外的顶点增大线角看起来更圆
- End Cap Vertices (终端顶点的圆角)
- UseWorldSpace对齐世界坐标还是本地坐标
材质什么的要么一看就懂要么就是不太重要的 面板里的东西和代码属性一一对应点进去看就行,注意怎么代码加点
lineRenderer.positionCount = 4; // 必须要先规定个数
lineRenderer.SetPositions(new Vector3[] {
new Vector3(0, 0, 0),
new Vector3(0, 0, 5),
new Vector3(5, 0, 0)
});
lineRenderer.SetPosition(0, new Vector3(0, 0, 0)); // 重载可以指定操作
音频系统 和 视频播放
常用兼容格式mp3 wav ogg aiff
面板下面能预览,参数中比较重要的是 LoadInBackground后台加载不阻塞主线程 LoadType性能和内存取舍参数 Preload Audio Data场景使用前是否预加载音效
quote
参数一览
- Force To Mono 强制为单声道时,混合过程中被标准化 适合需要将多声道音频转换成单声道的情况
- Load In Background 在后台加载,不会阻塞主线程,有助于提高应用的响应速度和用户体验
- Ambisonic 立体混响声,非常适合360度视频和XR应用程序 如果音频文件包含立体混响声编码的音频,请启用此选项
- LoadType (加载类型)
- Decompress On Load 不压缩形式存在内存中,虽然会占用更多内存但可以快速加载 适用于小音效
- Compress in memory 压缩形式存在于内存中,尽管加载时间较长但是能节省内存空间 仅适用于较大音效文件
- Streaming 以流的形式存在,使用时解码 这种形式对内存的影响最小,但可能会增加CPU的消耗
- Preload Audio Data 勾选后进入场景即加载音频数据;不勾选则在第一次使用时才加载,有助于管理首次加载时间
- Compression Format (压缩方式)
- PCM 音频以最高质量存储,适合对音质有高要求的应用场景
- Vorbis 相比PCM,提供了更高效的压缩率,根据所需的质量级别调整压缩程度
- ADPCM 包含一定的噪音,适用于会被多次播放的声音,如碰撞声或环境音效
- Quality (音频质量) 确定要应用于压缩剪辑的压缩量 不适用于 PCM/ADPCM/HEVAG 格式
- Sample Rate Setting (采样率设置) PCM 和 ADPCM 压缩格式允许自动优化或手动降低采样率
- Preserve Sample Rate 此设置可保持采样率不变(默认值)
- Optimize Sample Rate 此设置根据分析的最高频率内容自动优化采样率
- Override Sample Rate 此设置允许手动覆盖采样率,因此可有效地将其用于丢弃频率内容
AudioSource组件是让 GameObject发出声音的组件。AudioListener是声音监听器,可以理解为声音总线,一般会跟随摄像机挂载有且仅有一个多了删掉,如果出现了多个AudioListener会报警告。 注意参数:
- Play On Awake (启动播放开关):物体Awake事件时立即播放
- Spatial Blend (空间混合) 2D 音源(0)例如给UI用固定、3D 音源(1)随GameObject有远近,或者是二者插值的复合音源。一般只会01
- Volume Rolloff 声音随距离衰减速度,选择不同的模式看曲线就知道了,3D的游戏用线性比较真实
quote
参数一览
- AudioClip (音频剪辑):播放的音频
- Output 默认将直接输出到音频监听器(默认为MainCamera挂的)可以更改为输出到混音器
- Mute 静音开关(静音了不是不播放了)占用内存小可以放很多音效,因此可以让画面快速响应关闭和恢复当前音效
- Bypass Effect 开关滤波器效果
- Bypass Listener Effects 快速开关所有监听器
- Bypass Reverb Zones 快速开关所有混响区
- Play On Awake 对象创建时就播放音乐,也就是开关启动游戏就播放
- Loop 循环
- Priority 优先级
- Volume 音量大小
- Pitch 音高
- Stereo Pan 调节左右声道
- Spatial Blend 音频受3D空间的影响程度
- Reverb Zone Mix 到混响区的输出信号量
- 3D Sound Settings 和 Spatial Blend 参数成正比应用
- Doppler Level 多普勒效果等级
- Spread 扩散角度设置为3D立体声还是多声道
- Volume Rolloff 声音衰减速度
- Logarithmic Rolloff 对数衰減
- Linear Rolloff 线性衰减
- Custom Rolloff 自定义曲线衰减
- Min/Max Distance 开始衰減的最小距离/声音可听到的最大距离
使用代码控制音频
没开始Play是不能Pause的。动态控制音频常用的方式,可以直接搞好挂对象身上;可以挂在一个prefab上然后动态生成销毁;可以通过指定clip用一个AudioSource组件管理。PlayOneShot
允许你在一个已有的 AudioSource
组件上播放一次AudioClip
,而不会影响当前正在播放的音频,例如播放背景音乐的同时播放角色行走或射击的音效
AudioClip类要用浮点数组存储,长度是 声道数x剪辑长度 可以Get和Set数据
麦克风输入音频 frequency默认就填44100,返回的是一个AudioClip
Warning
一个AudioSource管一个音效,如果使用同一个, 那PlayOneShot和Play可能会会被一块触发导致重叠播放,比如上面想用按键控制攻击音效的时候,背景音乐同时也会跟着起停。所以改为了获取两个组件(问题来了我怎么知道我获取的组件顺序哪个是BGM哪个是攻击)
Unity 在内部使用的是 底层音频系统(如 FMOD、OpenAL、平台音频 API) 来初始化音频设备不会动态检测设备热切换,官方确认这是预期行为暂时没有解决方案
音频组件的基本API
public class AusT : MonoBehaviour
{
private AudioSource aus;
public AudioClip clip;
public AudioClip RecordClip;
// Start is called before the first frame update
void Start()
{
aus=GetComponent<AudioSource>();
string[] microps=Microphone.devices; // 所有的麦克风设备
foreach (string microp in microps)
{
print(microp);
}
}
// Update is called once per frame
void Update()
{
if(Input.GetKeyDown(KeyCode.P)) aus.Play(); // 这个Play的delay参数是根据采样率来的,最好别直接用
if(Input.GetKeyDown(KeyCode.S)) aus.Stop();
if(Input.GetKeyDown(KeyCode.Space))
{
// 实际上Unity没有提供音效是否播放完毕的API,用isPlaying一直检测,直到播完
if(aus.isPlaying) aus.Pause(); // 暂停再开始会接着播
else aus.UnPause(); // 暂停之后用也行
}
if(Input.GetKeyDown(KeyCode.C))
aus.clip=clip; // 更换clip
if (Input.GetKeyDown(KeyCode.R))
{
// 设备使用默认选null loop如果录制超长会重录 录制长度(S) 采样率一般就这个固定数44100
RecordClip= Microphone.Start(null, false, 10, 44100);
}
if (Input.GetKeyUp(KeyCode.R))
{
print(Microphone.IsRecording(null)); // 录制完了吗
Microphone.End(null); // 结束
aus.PlayOneShot(RecordClip); // 不知道为什么我这里能录制,能调用麦克风 但播放会有问题
float[] f = new float[RecordClip.channels*RecordClip.samples]; // 浮点数组存储,存储的长度是 声道数*剪辑长度
RecordClip.GetData(f, 0); // 直接Get出来塞进去
}
}
}
视频播放内置的VideoPlayer已经很好用了没必要玩的三方。MovieTexture很老了别用。
一般都用mp4+H.264就够,平台发布比较广可以使用VP8比h264性能差点兼容性好。
支持h.265编解码器的平台macOS 11.13+
,Windows 10
,Android 5.0+
,iOS 11.0+
。
平台兼容性 | Windows | macOS | Linux |
---|---|---|---|
.asf | ✓ | ||
.avi | ✓ | ||
.dv | ✓ | ✓ | |
.m4v | ✓ | ✓ | |
.mov | ✓ | ✓ | |
.mp4 | ✓ | ✓ | |
.mpg | ✓ | ✓ | |
.mpeg | ✓ | ✓ | |
.ogv | ✓ | ✓ | ✓ |
.vp8 | ✓ | ✓ | ✓ |
.webm | ✓ | ✓ | ✓ |
.wmv | ✓ |
更多平台兼容性参考
- Windows: Supported Media Formats in Media Foundation
- macOS: Media Layer Documentation
- Android: Media Formats
- iOS: iPhone Compare
目标平台兼容性:
- 视频与UnityEditor兼容,和目标平台不兼容:unity提供了转码功能
- 视频只与目标平台兼容,但是不一定能在UnityEditor播放:使用宏执行对应平台代码段
#if
视频的Inspector参数一览
- sRGB: 开启sRGB:统一色彩标准确保在不同设备上避免色差默认勾选
- Transcode(转码)
- AspectRatio: NoScaling不拉申有黑边;Stratch拉伸没黑边但是会失去比例;影响Dimensions的选项
- Dimensions尺寸调整
- Original(保持原始大小):不改变视频的原始尺寸(一般是这个
- Three Quarter Res:其原始宽度和高度的四分之三
- Half Res:原始宽度和高度的一半
- Quarter Res:原始宽度和高度的四分之一
- Square (1024x1024):1024x1024的正方形,宽高比可控制
- Square (512x512):512x512的正方形,宽高比可控制
- Square (256x256):256x256的正方形,宽高比可控制
- Custom:允许用户自定义视频的分辨率,宽高比同样可以控制
- Codec(编解码器选择)
- Auto:自动为所选目标平台选择最合适的视频编解码器。
- H264:MPEG-4编+AVC解,广泛支持
- H265:MPEG-H Part 2编+HEVC解 提供更高的压缩效率
- VP8:一种视频编解码器,受大多数平台上的软件支持,并且在Android和WebGL等几个平台上获得硬件支持
- SpatialQuality质量和压缩看着来就行
原生VP8编解码器在Android平台上并不支持透明度所以必须启用转码。目的是为了让Unity等应用程序能够使用其内部机制来处理和表示Alpha通道.AudioSampleProvider buffer overflow. XXXX sample frames discarded.
某些平台导入视频之后会出现丢失音频帧没声音,不知道为啥重启就好了。
视频播放使用VideoPlayer组件,关联你的VideoClip。
先看Inspector:
- Source->URL视频源可以本地可以网络地址
- WaitForFirstFrame,准备完了才开播不会丢弃。播放的时候程序是需要一点点准备的,准备消耗的时间也算上,播的时候前面那些丢掉从消耗后的时间开始播
- RenderMode->RenderTexture 把视频输出到Image(这样就可以作为背景了)思路就是使用 输出到RawImage(大小最好和视频一样) 就可以放UI中使用了;MaterialOverride可以覆盖物体MeshRender的材质
- VideoAspectRatio 缩放比例
- Fit Vertically(优先适配高度)左右黑边或裁剪;Fit Horizontally(优先适配宽度)上下黑边或裁剪
- Fit Inside(适应内部)不裁剪有黑边优先自适应
- Fit Outside(适应外部)保留宽高比有裁剪无黑边,充满区域
- Stretch(拉伸)不会保留源宽高适应播放窗,视频可能变形
- AudioMode的Direct是直接通过Unity播放,AudioSource是通过一个音频源让场景上的音频监听器播放
看代码面板上有的一眼能对上的不写。
VideoPlayer基本API
public class videoplay : MonoBehaviour
{
public RenderTexture texture;
public VideoClip clip;
VideoPlayer videoPlayer;
void Start()
{
videoPlayer = Camera.main.gameObject.AddComponent<VideoPlayer>();
videoPlayer.playOnAwake = false;
videoPlayer.renderMode = VideoRenderMode.CameraFarPlane;
//videoPlayer.targetTexture = texture; // 贴图模式才能用
//videoPlayer.targetCamera
videoPlayer.targetCameraAlpha = 0.5f;
//videoPlayer.targetCamera3DLayout = Video3DLayout.OverUnder3D;
videoPlayer.source = VideoSource.VideoClip;
videoPlayer.clip = clip;
//videoPlayer.source = VideoSource.Url;
//videoPlayer.url = Application.streamingAssetsPath + "/Video.mp4";
print(videoPlayer.length);//总长多少秒
print(videoPlayer.time);//播放了多少秒 可以设置它以从某个时间播放
print(videoPlayer.frameCount);// 总帧数
print(videoPlayer.frame);// 当前播放了多少帧
videoPlayer.Prepare(); // 需要先准备完才能播,可以提前准备,搭配WaitForFirstFrame用。
videoPlayer.prepareCompleted += (v) => print("准备完成事件");// 这里就可以传标志位准备好了按键再播
videoPlayer.started += (v) => print("开始Play的事件");
videoPlayer.loopPointReached += (v) =>print("结束Play的事件");
}
void Update()
{
if(Input.GetKeyDown(KeyCode.Space))videoPlayer.Play(); //视频播放
if (Input.GetKeyDown(KeyCode.S))videoPlayer.Stop(); //视频停止
if (Input.GetKeyDown(KeyCode.P)) videoPlayer.Pause(); //视频暂停
}
}
全景视频先不搞
动画系统
常识类的跳过,窗口在Window->Animation->Animation,创建对象可以Assets->Create->Animation。动画除了K帧按钮之外还可以添加事件,播放到这个地方的时候就会触发指定的事件。 面板上定位的不是秒数而是当前帧数,默认是60fps
关键对象:
- AnimationClip 一段动画(描述信息),把这段动画拖到想施加的对象身上(可以加多个对象),就会产生一个动画状态机AnimatorController,并且为对象增加了Animator组件关联到此状态机。
- 状态机中可以多次拖入多个AnimationClip
- AnimationClip的Inspector 更多 中可以切换为Debug模式,主要是可以调整SampleRate每秒多少帧,和WrapMode播放模式
- 在动画Animation窗口的 更多 中也可以切换帧率和以秒显示时间轴。
新动画系统,Mecanim动画系统主要用Animator
状态机组件来控制动画。
人物的动作是有限的,且同一时间只能有一个动作状态,而且随时可以切换(任意状态),动画和游戏AI是有限状态机应用的绝佳场景。
AnimatorController可以在Animator窗口中编辑,注意窗口中的重要参数:
- Layers可以为动画分层级,调整Weight权重可以让动画播放覆盖或者融合,越靠近1权重越大。
- Parameters可以添加基础类型的参数。
- 默认就带三个状态,初始Entry,Exit退出,AnyState任意。橙色的动画和Entry相连,表示进入的默认状态,灰色是自己拖进来的。状态AnimationState是关联动画AnimationClip的。
- 播放到最后一个状态是会一直loop,双向 连线Transition 会两个动画循环播放
- 每个Transition中都可以绑定参数列表Parameters中的参数,设置参数对应的条件,满足条件才能够触发动画,多个条件是与判断
- Tirgger类型 置为true触发后会自动变回false,一般用来点一下触发一次动画,然后自动返回上一个状态的动画
- Transition的HasExitTime属性控制切换动画的时候是不是要把当前这个播放完
- 直接Play就可以指定播放但是一般不用
多种怪物和角色复用状态机,Project->右键Create->AnimatorOverrideController,关联需要继承和改变的状态机,然后就可以替换所有的AnimationCliper,达到保留原状态机调整的参数和事件,只替换掉动作复用的效果。
和AnyState连接的Transition要取消勾选CanTransitionToSelf,不然死了会一直自己切换自己,循环播放最后一个一直抖
使用AnimatorController代码控制动画
public class AnimeTest: MonoBehaviour
{
Animator animator;
void Start()
{
animator = GetComponent<Animator>();
animator.runtimeAnimatorController = Resources.Load<RuntimeAnimatorController>(monsterInfo.animator); // 可以在运行时通过Resources动态加载Animator资源赋值
}
void Update()
{
bool b1 = animator.GetBool("b1");
bool b2 = animator.GetBool("b2");
animator.SetBool("b1",true);
// 可用API包括 SetFloat SetInteger SetTrigger;Get同理
}
}
Warning
新老动画系统中的AnimationClip不能通用
拖进去之后AnimatorController中的State绑定不了旧Clip,可以手动转换或者创建新的。
老动画系统,Animation动画系统用Animation
组件代码来控制动画
在需要动画的物体上挂载Animation组件,此时不会在自动创建Animator和AnimatorController,这个组件上的参数:
- Animation和Animations默认播放 和 绑定了可以用代码获得的 AnimationClip
- CullingType: Always一直播放或者BasedOnRenderers渲染不到的时候不播放 当你 要播放动画的状态 和 动画开始状态 不一样的时候才会产生过渡效果,系统会自动计算,比如A->B,A->C。播放A->C的时候会先把物体从B状态过渡到A状态(不一定是还原。
一般动画和属性改变应该是分离的
使用Animation组件控制动画
public class TestAnimation : MonoBehaviour
{
private Animation anim;
void Start()
{
anim = GetComponent<Animation>(); // 获得动画组件
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Alpha1))
{
anim.Play("anime1"); // 播放动画1
}
if (Input.GetKeyDown(KeyCode.Alpha2))
{
anim.Play("anime2"); // 会把当前动画中断去播放动画2
anim.CrossFade("anime2"); // 把当前动画中断但是系统会自动计算一个插值,去播放动画2,看起来更顺畅
anim.PlayQueued("anime2"); // 队列播放,放完上一个才放动画2,但是也没有过渡
anim.CrossFadeQueued("anime2");
}
if (Input.GetKeyDown(KeyCode.S))
{
anim.Stop();// 停止所有动画
}
if (anim.isPlaying == true)
{
Debug.Log("有动画正在播放");
}
if (anim.IsPlaying("anime2"))
{
Debug.Log("正在播放动画2");
}
}
public void AnimationEvent(int param1) // 界面上可以为动画事件传参数,什么都可传
{
Debug.Log($"Trigger AnimationEvent total Frame: {Time.frameCount}");
}
}
3D模型
导入规范上面写了不重复,一个fbx Assets 的设置主要这四个Model Rig Animation Materials
先记一下需要加深印象的参数,其他参数用到了去查或者问AI
Model:
- GlobalScale 当模型中的比例不符合项目中的预期比例时,修改此值来改变该模型的全局比例。Unity的物理系统希望游戏世界中的1米在导入模型文件中为1个单位
- ConvertUnits启用可将模型文件中定义的模型比例转换为Unity的比例。不同的格式的比例
.fbx .max .jas = 0.01
.3ds = 0.1
.mb .ma .lxo .dxf .blend .dae = 1
- MeshCompression是不是压缩一下网格节省性能
- Read/Write是否在内存中保留网格信息以供读写,增加内存占用。
- 需要在代码中读取或写入网格数据。
- 需要运行时合并网格
- 需要使用网格碰撞器时
- 需要运行时使用NavMesh构建组件来烘焙NavMesh时
- WeldVertices合并在空间中共享位置的顶点,前提是这些顶点总体上共享相同属性(UV 法线 切线),能优化网格的顶点计数,一般开启除非你想运行的时候获取并修改顶点
Rig: - AnimationType
- None环境等不需要骨骼和动画的
- Humanoid人形的模型,可以指定此模型的Avatar信息,可以使用模型自己的或者复制其他模型的Avatar信息
- Generic 通用不规则模型,比人形多一个RootNode参数,设置用于此Avatar的根节点骨骼
- Avatar化身系统,让人形模型已经绑定好的模型动作可以在其他的人形模型上复用,Assets中编辑Avatar或者在模型Rig中Configure就可以打开编辑页
- Mipping为标准人形对应模型中的骨骼,在页签左下可以保存/读取到文件,自动应用等。姿势建议使用强制T姿势,美术一般也使用这个。
- Muscles&Settings肌肉群设置。可以设置不同姿势旋转变化预览映射关系是否合理。Pre-MuscleSettings设置骨骼在旋转时的范围限制,TranslationDoF如果需要骨骼长度变化拉伸的动画才开启。
Animation:
动画文件是可以单独导出的,建议美术导出 只包含网格信息不包含动作的模型 与 只包含动作不包含网格信息的文件 动画导出标准
- ImportAnimation是否导入动画的总开关
- Anim.Compression导入动画时使用的压缩类型
- 禁用压缩效果好耗性能
- Keyframe Reduction(减少冗余关键帧)仅适用于Generic通用动画类型
- Keyframe Reduction and Compression(减少关键帧并在文件中存储动画时压缩关键帧)仅适用于Legacy旧版动画类型会影响文件大小,但内存大小与Keyframe Reduction相同
- Optimal(让Unity决定如何压缩) 仅使用Generic通用和Humanoid人形动画类型
- Clips 动画在导出的时候可能会把一个长的动画导出,然后根据关键帧切成多个Clips,在这里可以添加Clips
- LoopTime动画是不是循环播放,Loop Pose无缝循环 和Loop Match平滑过渡,CycleOffset第一次播放从哪一帧开始一般不调整。如果属性后面的灯绿了说明是开始结尾对齐的。
- 在RootTransformRotation和下面三个参数中,有个最重要的属性BakeIntoPose烘焙到动作 此选项决定动作产生的移动是否应用到根还是只有动画动作,下面几个也是根据不同轴的平面来分的。
- Mirror人形镜像反转只有人形能用
- Curves可以在动画播放过程中按照曲线更改一个同名的自定义值,创建一个动画状态机和条件值,把动画扔进去状态机应用到人物,新建一个和曲线名称相同的变量,就可以在代码中获得这个随着播放曲线变化的值(没啥用
- Events动画事件(和下面的 状态机行为脚本 对比一下),可以在指定帧插入一个Event,确定Event的名字和要传的参数,啥都不改就是没有参数。 动画过渡时间不会触发 他会在运行的时候查找所有挂载在物体上的脚本执行同名的函数,找不到就报错
- Mask编辑遮罩,可以让遮罩的部位动画失效,人形的自带了一个,非人形的要自己绑
预览窗口可以查看IK查看质心 根等,可以拖模型进来预览等。
Materials:
- MaterialImportMode 导入模型使用的材质
- None 不使用此模型的任何材质,用Unity默认的漫反射材质
- Standard使用Unity默认规则来生成材质
- ImportviaMaterialDescription使用FBX中嵌入的材质来生成Unity中的材质(但注意,材质一半是在游戏内部来设置的,建模中的材质可能和Unity中的材质不一样,很多时候不选。
- UseSRGBMaterialColor是否在伽玛空间中使用反射率颜色,使用线性颜色空间时禁用此选项,使用FBX嵌入的材质时此选项不会出现。
- Location选择UseEmbeddedMaterials可以在下面提取嵌入的Material材质和Texture贴图
- EthanWhite如果解析出来了使用哪个材质,一般解析成功了就会默认一个正确的
- Naming 材质的命名 Search就是从哪些地方搜索此模型要用的材质
状态机行为脚本 在State上可以挂脚本,这个脚本比较特殊,继承StateMachineBehaviour,当角色进入、退出或保持在某一个特定状态时,可以通过这些脚本进行一些逻辑处理。就是在AnimatorController状态机窗口中的某个状态添加一个脚本来实现一些特殊功能,比如播放声音检测落地播放特效等
主要的生命周期函数,必须overraid指定的函数进行操作,参数都是一样的。+
Animator animator 挂载的动画组件
AnimatorStateInfo stateInfo 脚本挂载的状态
int layerIndex 状态所在的层
OnStateEnter 进入状态时,第一个Update中调用
OnStateExit 退出状态时,最后一个Update中调用
OnStateIK 在OnAnimatorIK后调用
OnStateMove 在OnAnimatorMove后调用
OnStateUpdate 除第一帧和最后一帧,每个Update上调用
OnStateMachineEnter 子状态机进入时调用,第一个Update中调用
OnStateMachineExit 子状态机退出时调用,最后一个Update中调用
状态机行为脚本 和 Events动画事件相比更精确,但是更麻烦
混合动画
动画分层 比如上半身播放开枪动画,下半身可以蹲跑跳,由不同的动画层组合播放。在Animator窗口中,添加状态机Layer,每个Layer对动画的影响是同时施加的。 Layer的参数:
- Weight 权重,选择叠加模式Addtive的时候混合占比多高;使用覆盖模式Overraid的时候,谁权重最高就播放哪个。第一层强制为1最高
- Mask遮罩,可以选择一个遮罩对该层动画都生效而不需要修改模型的遮罩(遮罩可以在Assets中创建Avatar
- Sync 同步,开启之后可以选择一个Layer同步,此时会复制这个Layer中的所有状态,可以为复制过来的State更改绑定的Motion,然后通过代码更改Weight达到按照权重混合的模式,比如按照血量更改走路姿势等。
- Timing勾了会让复制过来的State播放速度根据Weight改变
- 多层动画叠加播放的时候,比如上半身射击下半身跑走,用mask隔开,上半身Layer的可以创建一个空状态作为默认状态,然后利用Trigger触发
混合树
在AnimatorController->CreateState->FromNewBlendTree创建一个混合树,双击混合树可以编辑。
在Blend参数那可以选择类型,1D混合树就是只用一个参数控制的混合树,此时就已经自动创建了一个参数。
此时就有一个可以调节的二维坐标,X轴即为变量,Y轴即是播放Motion里哪个的Threshold权重,会按照权重混合起来播放。可以在Motion中调整权重和速度 。
AutomateThreshold自动权重会自动平分权重(一般就是0-1之间的权重,使用混合树可以达到参数逐渐增加动作从idle->walk->run这个效果,当然衔接和相似度越高看起来越好。
2D混合就是两个参数控制动画混合程度。在Paramters中指定绑定的参数。
- 2D Simple Directional 2D简单定向模式 运动表示不同方向时使用 比如向前、后、左、右走
- 2D Freeform Directional 2D自由形式定向模式 同上 运动表示不同方向时使用 但是可以在同一方向上有多个运动 比如向前跑和走
- 2D Freeform Cartesian 2D自由形式笛卡尔坐标模式 运动不表示不同方向时使用 比如向前走不拐弯 向前跑不拐弯 向前走右转 向前跑右转
- Direct 直接模式 自由控制每个节点权重 一般做表情动作等(大型游戏才会做表情系统
混合树嵌套看混合树的工作流图,就可以知道怎么嵌套的
此时在图表中就可以编辑不同的Motion的权重位置,三种2D混合树只是算法不同,作用是一样的。
子状态机
可以把多个状态机连成一个子状态机,直接在AnimatorController->Create->SubStateMachine.
注意: 连接UpperStateMachine的时候,如果直接连出去会播放上层的默认动画,连过去的时候右键UpperStateMachine可以选择转移到上层哪个状态
IK控制 在动画Animator窗口->Layer的设置IK Pass此层的动画才会受到IK影响,IK动画控制用来捡东西弓箭锁定瞄准,上半身随着鼠标旋转下半身走路等场景。
IK在Update和LateUpdate之间被调用,也就是说会在每帧的状态机和动画处理完后调用OnAnimatorIK(处理IK)在OnAnimatorMove(处理根运动)之前调用
IK代码控制
public class CodeIK : MonoBehaviour
{
public Animator ant;
public Transform pos;
void Start()
{
ant = this.GetComponent<Animator>();
}
private void OnAnimatorIK(int layerIndex) // 特殊生命周期函数
{
ant.SetLookAtWeight(1,1f,1f);// 设置头部IK权重,这函数重载了5次,注意参数,去界面上调一调。
// weight: 全局权重
// bodyWeight: 身体的权重
// headWeight: 头部的权重
// eyesWeight: 眼睛的权重
// clampWeight 限制角色转动幅度的权重0不限制1完全不能转动0.5转一半
ant.SetLookAtPosition(pos.position);// 设置头部IK看向的位置,可以目光和头跟着位置动
ant.SetIKPositionWeight(AvatarIKGoal.RightHand, 1); // 设置Avatar某个IK点(四个枚举可以在Avatar骨骼那里查看)的位置权重
ant.SetIKRotationWeight(AvatarIKGoal.RightHand, 1); // 旋转权重
ant.SetIKPosition(AvatarIKGoal.LeftHand,pos.position);
ant.SetIKRotation(AvatarIKGoal.LeftHand,pos.rotation);
}
}
动画目标匹配,比如跳过障碍,脚指定落点抓住房梁等。
Warning
调用目标匹配限制
必须保证动画已经切换到是目标动画上,不能在(前后)过渡阶段,开启ApplyRootMotion根运动
注意调用方式,可以通过动画的Events触发,保证满足目标匹配的触发限制
动画目标匹配
public class MatchMotion : MonoBehaviour
{
private Animator ant;
public Transform jumpTarget;
void Start()
{
ant = GetComponent<Animator>();
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
ant.SetTrigger("Jump"); // 只触发动画
}
}
public void Jump() // 通过动画的Events触发,保证满足目标匹配的触发限制
{
Debug.Log("jump trigger");
ant.MatchTarget(jumpTarget.position,// 目标位置,角度
jumpTarget.rotation,AvatarTarget.LeftFoot,// 匹配的骨骼位置播放完之后哪个位置要和指定的位置重合
new MatchTargetWeightMask(Vector3.one, 1),// 位置角度权重。位置权重X/Y/Z 轴的权重都是1,表示完全匹配位置(移动到目标位置)旋转匹配权重:1,表示完全匹配旋转(朝向与目标一致)。
0.4f,// 开始位移动作的百分比,播放到百分之多少开始位移
0.63f);// 结束位移动作的百分比
}
}
角色控制器
人形Avatar更合适角色控制器,添加角色控制器之后不需要再添加刚体,能检测触发器 碰撞 射线,使用的时候需关闭Anmiator的ApplyRootMotion
使用角色控制器的好处是可以避免使用刚体判断碰撞时容易撞飞斜坡滑动抖动等角色bug,控制角色更稳定。 CharacterController组件,用了这个就不用Collider和Rigidbody, 自带一个Collider不过形状未知。
- Slope Limit: 控制角色最大的爬坡斜率
- Step Offset: 控制角色可以迈上最大的台阶高度
- Skin Width: 在角色的外圈包裹着一层“皮肤”的厚度,如果Skin Width设置为1米,那么角色当然就会“浮空”1米,一般保持默认即可
- Min Move Distance: 最小移动距离,默认为1毫米,如果该数值过大但代码中单位移动速度很慢,角色就不会动
- Center/Radius/Height: 角色控制器组件在Scene面板中体现为一个“胶囊碰撞器”的形状,这也导致其他的碰撞体我们并不需要,Center为胶囊中心点位置,Radius为半径,Height为高度
- 不使用 Rigidbody 的物理系统,也就是说,它不会被力推着走也不会自动转动,纯粹的碰撞盒 + 自己写的运动逻辑例如
controller.Move(Vector3)
- 所以如果要让CharacterController转向需要
注意组件的方法characterController.isGrounded
返回布尔值,检测是否在地面上。characterController.Move(vector3.forward);
和transform.Translate
类似,没有重力适合平地,需要传递向量,数值大小会直接影响移动速度,所以要乘Time.deltaTime
characterController.SimpleMove(vector3.forward);
在Update里调用会自动应用重力,需要传递一个速度,不受帧率影响,不需要乘Time.deltaTime
public void OnControllerColliderHit(ControllerColliderHit hit){}
额外提供了碰撞检测函数,接收被碰撞的对象,但是常规的碰撞函数就没用了,常规的触发器函数还有用
寻路导航
Unity的寻路本质是A星寻路的拓展和优化,A星只支持2D和静态阻挡,navi3D和动态阻挡都支持。
2代和一代不太一样,新版本的支持动态烘焙
注意这几个重要的对象 导航网格 (NavMesh) 导航网格寻路组件 (NavMesh Agent) 导航网格连接组件 (Off-Mesh Link) 导航网格动态障碍物组件 (NavMesh Obstacle)。 导航的单位都是米
做导航的流程,定义地形和消耗,烘焙,设置导航寻路Agen,设置障碍。
Warning
注意如果已经烘焙好了导航网格会无视碰撞体直接穿过去,所以最好障碍一块烘焙导航网格,烘焙网格不能运行过程中动态生成。
老版本
2022之后的版本需要额外安装Window->PackageManager->AI Navigation才能启用老版本。装完在Winodow->AI->Navigation(Obsolete)打开。
地形烘焙:
Object 中可以过滤场景中物体,方便设置导航属性,在Static中设置导航静态NavigationStatic物体才能参与地形烘焙。Terrins是挂载了的地形脚本的对象
Area 中可以规定导航区域的寻路消耗,在Object的 NavigationArea中选择,寻路的时候会根据消耗和路径选择消耗最少的路径。 Bake 参数一览:
- AgentRadius 烘焙边缘精确度,可行走区域的padding
- AgentHeight 拱桥是否可穿越的高度
- MaxSlope 斜坡是否可以行走的度数
- StepHeight 最小楼梯高度,台阶是否可以行走
- Generated off mesh Links
- DropHeight 可以从小于此高度掉下来继续寻路
- JumpDistance 小于此间距的视为可以跳跃过去继续寻路,只要你设置了小于这个间隙的就一定能跳过去无视性能,你说这扯不扯
- VoxelSize 设置(立)体(像)素大小,控制烘焙的准确度,立体像素大小减半会使内存使用量增加4倍,构建时间也增加4倍,除非想要极其准确的导航网格再去修改
- MinRegionArea 面积小于此处值的导航网格区域会被移除
- HeightMesh高度网格构建开关 解决楼梯烘焙为斜坡时,希望在楼梯表面的位置 准确的放置在楼梯平面而不是斜坡上,启用它会增加烘焙时间
- Bake完之后会在场景下同名文件夹生成对应的烘焙数据。
Agents 中的参数和bake差不多 调整某个对象的代理对象,默认使用人形代理。
NavMeshAgent组件:需要导航的对象挂上就多了一个寻路检测盒,参数:
- AgentType 代理类型,使用Agents中哪个预设的参数用
- BaseOffset 寻路检测盒的偏移
- Steering移动设置
- Speed寻路时最大移速 Angular Speed寻路时转身的最大旋转速度(度/秒) Acceleration最大加速度
- StoppingDistance 距目标点小于此距离时停止移动
- Auto Braking 到达目标点自动制动 连续移动比如巡逻移动时不要开启
- ObstacleAvoidance 避障设置
- Radius/Height 寻路检测盒的半径/高度
- Quality 障碍躲避精度,越高躲避障碍越准确,但是性能消耗较大,如果不想主动避开其它动态障碍,可以设置为无,则只会解析碰撞 #todo
- Priority 优先级,0~99,避障时,数字较小的障碍物表示较高的优先级优先级低的会忽略避障 #todo
- Auto Traverse OffMesh Link是否开启自动遍历网格格外的其它网格连接
- PathFinding寻路规则
- AutoRepath 是否开启自动重设路线,当到达路径后段时会再次尝试寻路,到不了的时候就找个离目标最近的点,经典RTS人在对岸小兵卡住
- AreaMask 寻路时纳入的区域,寻路时不想考虑某些区域,则取消选中塔防游戏中常见的功能
NavMeshAgent的API
void Update()
{
if (Input.GetMouseButtonDown(0))
{
Debug.Log("鼠标点击导航点");
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit))
{
agent.SetDestination(hit.point); // 设置导航目标点
agent.isStopped = false; // 停止导航,再次导航必须打开
Debug.Log("目标点: " + hit.point);
}
}
if(agent.velocity.sqrMagnitude < 0.01f) animator.SetBool("run",false); // 利用速度判断动画状态机
else animator.SetBool("run",true);
}
#region 不常用API
print(agent.speed); //1.面板参数相关 速度
print(agent.acceleration); //1.面板参数相关 加速度
print(agent.angularSpeed); //1.面板参数相关 旋转速度
if(agent.hasPath){} //2-1当前是否有路径
print(agent.destination); //2-2代理目标点 可以设置 也可以得到
print(agent.isStopped); //2-3是否停止 可以得到也可以设置
print(agent.path); //2-4当前路径
if(agent.pathPending){} //2-5路径是否在计算中
print(agent.pathStatus); //2-6路径状态
agent.updatePosition = true; //2-7是否更新位置
agent.updateRotation = true; //2-8是否更新角度
print(agent.velocity); //2-9代理速度
NavMeshPath path = new NavMeshPath();
if(agent.CalculatePath(Vector3.zero, path)){} //计算生成路径
if(agent.SetPath(path)){} //设置新路径
agent.ResetPath(); //清除路径
agent.Warp(Vector3.zero); //调整到指定点位置
#endregion
agent.velocity 的开销极低 velocity 是 NavMeshAgent 的一个属性,本质是 Unity 内部已经计算好的一个 Vector3,取它只是一次内存读取。sqrMagnitude 只是 xx + yy + zz 三次乘法一次加法,比 Magnitude(会开平方)更省。 animator.SetBool 也是轻量操作 Animator 参数更新是在下一帧的动画系统中批量处理的,你调用 SetBool 只是把值写到一个缓存,并不会立刻重算整套动画。
OffMeshLink网格外连接组件 他可以挂在任何一个东西上,只要把start end 拖上去就行
参数:
- CostOverride -1或者0使用所在区域的消耗值,否则使用 所在
区域消耗值*CostOverride
- BiDirectional 让不让从end跳回start
- Activated 激活失活
- AutoUpdatePositions 在运行过程中动态修改跳跃点对象位置时,导航网格也更新
NavMeshObstacle动态障碍组件,给物体挂上就会有一个导航体积盒,可以运行中动态计算导航
Carve 该障碍会在烘焙网格上动态开孔,固定不动的建议为true(门栅栏什么的),移动的不用(玩家汽车什么的)
- MoveThreshold移动超过该距离时会认为其为移动状态,更新移动的孔
- TimeToStationary单位为秒 超过该值会认为真正静止
- CarveOnlyStationary(只有在静止状态时才会计算孔)
#todo 这个障碍被玩家点击破坏才能通过练习题核心P95
新版本
现在内置的已经是新版本了。 新版本把Bake和Object独立出来了,在任意组件上挂载NavMeshSurface组装一下,
你说熟悉Unity的导航寻路,那我问你--1.如何用网格表示世界_哔哩哔哩_bilibili
【Unity3D基础】【导航系统】新版本导航系统的使用方法_哔哩哔哩_bilibili
全网最详细Unity NavMesh寻路第一期:基础讲解,烘焙场景_哔哩哔哩_bilibili
粒子系统
类似下雪下雨法球之类的,很多都用到粒子,程序主要是使用粒子,而不需要自己做粒子。
粒子就是普通的空gameObject,但是挂载了Particle System(Create->Effects->ParticleSystem) 在ParticleSystem组件下,以下几个属性常用:
- Duration持续时间
- looping是否循环
- startLifetime每个粒子的存活时间
- playOnAwake是否一开始就播放,如果设置false,那就只能在代码里获得然后播放
- stopAction选项很明确,Disable播放完后禁用自己,Destory播放完之后销毁自己,Callback要自己去绑定回调方法了但很少这么做。
2D系统
在开始选择的时候2D和3D只是预设不同,其他的没什么区别,但是我也不知道为什么有些东西建了3D项目导入不进去2D就可以比如标准包里的瓦片
大致看一眼留个印象 | 2D 工程(默认设置) | 3D 工程(默认设置) |
---|---|---|
摄像机投影方式 | 正交 (Orthographic) | 透视 (Perspective) |
场景视图模式 | 默认启用 2D 视图 | 默认启用 3D 视图 |
对象坐标轴方向 | X(横)、Y(竖) | X(横)、Y(高)、Z(深度) |
图片导入类型 | Sprite | Texture(Default) |
精灵渲染组件 | SpriteRenderer | MeshRenderer |
碰撞体类型 | Collider2D 系列(Box2D) | Collider 系列(PhysX) |
刚体组件 | Rigidbody2D | Rigidbody |
地面检测/射线等默认行为 | 使用 Physics2D | 使用 Physics |
默认物理引擎 | Box2D(2D 物理) | NVIDIA PhysX(3D 物理) |
Tilemap 支持 | 默认包含(可用于地图编辑) | 需手动添加 Tilemap 支持 |
渲染管线 | 通常为 Built-in 或 2D Renderer(URP) | 通常为 Built-in、URP 或 HDRP |
照明系统 | 基本无光照(除非手动添加) | 支持光源、阴影、材质、反射等 |
素材相关
需要搞懂的几个流程概念:
- 建模
- 模型,面片,网格信息
- 展开UV
- UV,U轴和V轴
- 纹理贴图
- 材质(shader决定材质展现效果),纹理,贴图
- 骨骼
- 动画
概念不赘述,想不起来的搜。
伽玛颜色空间,详细的解释,Unity的颜色空间文档
建模一般使用三角形面片,因为在数学和物理上有天然优势。三角形是唯一总是共面的最小多边形,无论三个顶点怎么放,都始终在同一个平面上 的图形;图形硬件和渲染管线只支持三角形;三角形可以组成任意复杂模型,不存在“无法拼出来”的情况。建模和美术工具链中三角形是公认标准
图像材质导入
📌Tip
Unity支持的图片格式
- BMP:是Windows操作系统的标准图像文件格式,特点是几乎不进行压缩,占磁盘空间大。
- TIF:基本不损失图片信息的图片格式,缺点是体积大。
- JPG:一般指JPEG格式,属于有损压缩格式,能够让图像压缩在很小的存储空间,但一定程度上会损失图片数据,无透明通道。
- PNG:无损压缩算法的位图格式,压缩比高,生成文件小,有透明通道。
- TGA:支持压缩,使用不失真的压缩算法,还支持编码压缩。体积小,效果清晰,兼备BMP的图像质量和JPG的体积优势,有透明通道。
- PSD:是Photoshop(PS)图形处理软件专用的格式,通过一些第三方工具或自制工具可以直接将PSD界面转为UI界面。
- EXR
- GIF
- HDR
- IFF
- PICT 其中,Unity最常用的图片格式是JPG、PNG和TGA三种格式。
导入了图片之后在Inspactor面板可以选择材质类型(TextureType) 贴图,法线贴图等等。怎么这么多参数要记 TextureType类型,大致记一下就好,遇到增量补充:
- Default贴图
- sRGBTexture是否启用gamma颜色通道
- AlphaUsage透明通道使用哪个
- Normal map 法线贴图。法线是建模上垂直于每个点的法线,用高模生成法线贴图,在低模计算的时候应用法线贴图去计算材质和光照,减少性能消耗提高低模的表现效果
- CreateFromGrayscale 从灰度高度贴图创建发现贴图
- Bumpiness凹凸程度
- Filtering如何计算凹凸值Smooth标准法线Sharp使用更锐利的法线贴图
- 精灵Sprite
- SpriteMode: Single单张图 Multiple精灵图集
- Sprite PixelsToUnits场景中1m对应多少个像素默认100,UI自适应可能使用这东西进行计算
- Sprite MeshType网格模式,FullRect会生成一个覆盖此图片正方形,Tight基于像素的alpha值生成网格,多面片拼合形状;如果像素小于32x32会直接转换为FullRect。
- Pivot 九宫格轴心点,Single模式才有也可自定义
- Sprite GenerateFallbackPhysicsShape 根据精灵轮廓生成物理形状
- SpriteMode: Single单张图 Multiple精灵图集
- 其他一时半会用不到
- EditorGUIandLegacyGUI编辑器和IMGUI用的
- Cursor给自定义鼠标用的素材
- Cookie光源剪影
- Light Type Spotlight: 聚光灯类型,需要边缘纯黑色纹理。Directional: 方向光,平铺纹理。Point: 点光源,需要设置为立方体形状。
- Lightmap光照贴图
- SingleChannel单通道格式
- 高级设置
- 如果纹理尺寸非2的幂如何处理
- None: 不管
- To nearest: 整体缩放到最接近2的幂的大小(注:PVRTC格式要求纹理为正方形)
- To larger: 按照最大边尺寸缩放
- To smaller: 按照最小边尺寸缩放
- Read/Write会往内存中额外存如一些信息以便使用代码更改
- WarpMode材质贴图铺在此物体上的模式
- Repeat: 在区块中重复纹理
- Clamp: 拉伸纹理的边缘
- Mirror: 在每个整数边界上镜像纹理以创建重复图案
- Mirror Once: 镜像纹理一次,然后将拉伸边缘纹理
- Per-axis: 单独控制如何在U轴和V轴上包裹纹理
- Filter Mode 纹理在通过3D变化拉伸时如何进行过度
- Point: 块状马赛克
- Bilinear: 模糊
- Trilinear: 与Bilinear类似,但纹理也在不同的Mip级别之间模糊
- 如果纹理尺寸非2的幂如何处理
- Mipmap在右下角可以拖动预览层级
- StreamingMipmaps 使用串流使用计算性能换内存消耗
📝Note
Mipmap技术
在3D场景中,如果镜头离材质贴图很远,那么材质的像素就会被压缩采样,比如四个像素采样一个。这样会采样不足渲染失真,远景出现锯齿摩尔纹和运动噪点。
Mipmap技术就是预先从原分辨率材质生成不同层级大小的材质最小为1*1然后根据摄像机远近应用不同层级采样解决这个问题。
Mipmap的纹理比原始纹理小很多,更容易被GPU的纹理缓存容纳,可以提高缓存命中率,但是总显存占用率大约+33%
2D和UI一般不启用,3D项目显存不是特别紧张最好是开,没用到的地方就别打开。Mipmap的选择模式Bilinear选择最相邻的一个层级 和 Trilinear选择最相邻的两个层级进行插值采样
TextureShape 纹理形状,Cube用于天空盒和反射探针,Mapping是如何将纹理投影到游戏对象上,进阶的时候在天空盒会讲 纹理平台设置 很重要,和性能相关
- 最大纹理尺寸即便原图很大打包也会压到这个尺寸,一般2048
- ResizeAlgorithm 当纹理大于最大尺寸使用什么算法压缩
- Mitchell米歇尔算法:默认使用的算法,常用于尺寸缩小
- Bilinear双线性插值 :使用双线性插值来调整图像大小。对于细节很重要的图片,推荐使用此方法能保留更多的细节信息
- 不同平台支持的Unity材质格式看一遍有印象
- IOS默认使用PVRTC获得更大的兼容性,如果已经不包含OpenGL ES2的支持,可以选择ASTC比前面全方位好一些
- 安卓标准不统一,一般根据不同的设备制作多个不同的安装包,省事就ETC,追求一点性能就ETC2
- 构建一个以 OpenGL ES 3 为目标的 APK: 访问 Android 的 Player Settings (Edit -> Project Settings -> Player Settings, 然后选择 Android 类别) -> 向下滚动到 Graphics APIs 部分 -> 确保 OpenGL ES 2 不在列表中 -> 构建 APK (File -> Build Settings, 然后单击 Build)。
- 构建一个以 OpenGL ES 2 为目标的单独 APK: 访问 Android Player Settings -> 向下滚动到 Graphics APIs 部分 -> 在列表中添加 OpenGL ES 2 并删除 OpenGL ES 3 和 Vulkan -> 构建 APK。
- 压缩Compression和使用压缩算法Use Crunch Compression打包慢解压快
- Split Alpha Channel节约内存会把一张图分成两张纹理,一张包含RGB数据另一张包含Alpha数据在渲染时再合并渲染
- Override ETC2 fallback不支持ETC2的时候回滚格式
SpriteEditor
在Window->PackageManager->2D Sprite安上才能用,还不能用看看自己预设是不是选成了3D项目
- 注意Border和Pivot设置边框和中心点
- CustomOutline自定义边缘线,此边缘线外的会不渲染,节省性能
- CustomPhysicsShape自定义Collider的形状
- SecondaryTextures附加其他材质以供代码调用 使用Multiple模式可以切割图集
- Slice Type就是怎么切片,就Automatic用的最多
- Trim自动修剪透明区域
- Pivot规定的点会规定切出来之后每个图的中心点
- Method如果有手动切的应该怎么处理
SpriteRenderer
SpriteRenderer是可以挂载Sprite的组件。
- SortingLayers相当于PS中层的概念,每个Object属于哪个层,最下面的从层最靠前
- Order in Layer在层中的优先级
- DrawMode
- Simple模式就整体缩放
- Sliced 使用九宫格切分素材必须保证Sprite的MeshType: FullReact
- Tiled平铺模式
- TileMode 平铺的时候是平铺拉伸还是超过阈值就增加一层
- MaskInteraction 遮罩交互
- Sprite-Default默认的精灵材质不会受到光照影响
Sprite组件基本API
public class SpriteTest : MonoBehaviour
{
// 挂在Sprite对象上的组件
private SpriteRenderer spriteRenderer; // 声明一个spriteRenderer
public Sprite sprite; // 声明一个Sprite对象
void Start()
{
spriteRenderer = gameObject.GetComponent<SpriteRenderer>(); // 获得本Sprite的Renderer
spriteRenderer.sprite = sprite; // 可以替换掉
spriteRenderer.color = Color.cyan; // 其他属性参考着吗面板改
//动态加载图集
Sprite[] = sprs = Resources.LoadAll<Sprite>("RobotBoyIdleSprite");//这里是图集的名字,从数组里取对应的Sprite
}
}
可以直接Create->Sprite->形状,还没出美术素材作为替代资源,等出了之后换上就行了。
Create->SpriteMask可以创建遮罩,注意遮罩对象的CustomRange属性,只有在这个层级内遮罩才能起效,新加Layer是下方Layer的0-N,新Layer的0-Front
使用SortingGroup组件可以整体的加一个排序分组,比SpriteRender的AdditionalSettings->SortingLayer优先级还要高。子对象的SortingGroup只会在同级别发作用,对上层的无效,而且是优先于Z轴发挥作用的。
如果这个组件被放进了Prefabs中,拖出来的时候为了避免有同样值的SortingGroups冲突最好修改一下。
SpriteAtlas图集
打图集将多个纹理texture合并成一个大纹理,当访问图集中的多个纹理时,也只需要调用一次DrawCall, 默认是不会打图集的。在Edit->ProjectSettings->Editor->SpritePacker中打开
选择Sprite Atlas V2 - AlwaysEnabled
,右键在资源文件夹中Create->2D->Sprite Atlas,一般会通过代码动态加载它
Sprite Atlas V1 不支持缓存服务器(Cache Server),Unity只能将打包的图集数据存储在
Library/AtlasCache
文件夹中,并且也不能有依赖项,不支持命名对象导入器(named objects importer),而Sprite Atlas V2提供了对上述功能的支持。具体可以参考 Unity Manual:Sprite Atlas Version 2
主图集(Master)
- Include in Build:在当前构建中包含其他图集
- Allow Rotation:在打包图集时允许图片元素旋转,可提高图集密度; 注意 如果是UI图集,请禁用此选项,因为打包时会将场景中UI元素旋转 !!!
- Tight Packing:使用图片轮廓来打包而不是放到矩形中再排列,可提高图集密度
- Padding:图集中各图片的间隔像素 变体类型的图集(Variant)
- Master Atlas:关联的主图集(图集类型必须是Master)
- Scale:设置变体图集的缩放因子(0~1),变体图集的大小是主图集乘以Scale的结果,差不多就是对图集缩放。
把Texture或者包含Texture的文件夹拖进来就可以放进图集就行。如果发现没有PackPreview按钮,说明忘了打开允许图集。
直接使用Texture的时候,他会自动使用图集的,在 Game窗口->Stats(Statistics)->Batches就是DrawCall的数量看看有没有变,注意只有运行的时候才会变化
注意 如果一个图集的两个Texture中间有其他SortingLayer的Texture,此时图集会触发两次Drawcall,拼UI尤其注意 !
SpriteAtlas spriteAtlas = Resources.Load<SpriteAtlas>("MyAtlas"); // 和加载普通资源一样
sr.sprite = spriteAtlas.GetSprite("sprite1");
图集的尺寸最佳实践
如果 Sprite Atlas 设置的最大尺寸为 2048x2048,而所有 Sprite 加在一起超过了这个尺寸,Unity 会自动拆分成多个图集纹理(Texture),所以此时Drawcall也是两次。Sprite Atlas → Inspector → 下方可以看到 “Objects Preview”有没有被拆分 注意不同平台(Android/iOS)支持的最大纹理尺寸略有不同。旧设备普遍不支持大于 2048。
SpriteShape
同样需要在Window->PackageManager中安装才能使用,主要用来拼接2D游戏中不规则的地形的
SpriteShapeProfile
安装完此时右键在Assets右键Create->Sprite(创建SpriteShapeProfile)每个版本貌似路径还不太一样只要创建出这个文件就可以。
新版本的close和open的版本合并了,close的是封闭的一个圈可以使用很多;open就可以随便加折线编辑使用的是一个Texture。可以在圆圈上设置角度,然后点过去就可以设置这个角度区间的材质了
- UseSpriteBorders勾选这个东西之后它会使用九宫格拉伸的效果,默认选就行。
- Texture只有是repeat模式才能用于填充;Offset调整的是填充的时候偏移的角度;中间编辑的是角度区间使用的Sprite可以放多个然后用的时候选其中一个;角度边上的order是折角处相交显示优先级
- 下面的8个角的素材是这东西折叠起来的时候角上会用哪Texture
SpriteShapeController参数几乎一看就会
SpriteShapeRenderer
- Detail使用Sprite的质量
- CornerThreshold超过此角阈值才会使用角上的材质
- AdaptiveUV 自动判断纹理是平铺还是拉伸,宽度足够时会平铺不够时会拉伸,不开启则始终平铺但可能会出现裁剪效果
- Fill里面的参数PixelPerUnit(需要先禁用拉伸UV)像素/米比例尺,平铺密度
为这东西添加碰撞器有两种
边界碰撞器EdgeCollider2D会直接顺着中轴线添加碰撞器
多边形碰撞器PolygonCollider2D+复合碰撞器CompositeCollider2D,然后PolygonCollider2D->UseByComposite形成类似边界碰撞器的效果,多边形碰撞器必须使用Rigidbody,如果想让它不动就得选择成Static有检测碰撞限制
TileMap瓦片地图
需要在Window->PackageManager中安装2DTileMapEditor才能使用,也是用来创建2D地图。关键的是两个文件,一个是Tile.asstes文件和TilePalette预制体,点击预制体能打开TilePalette编辑器。
安装完后在 Asset->Create->2D->TilePalette 选择一个类型 或 Window->2D->TilePalette打开窗口,这样我们就创建了一个新的瓦片调色盘TilePalette,把这东西拖进场景中,再把Sprite拖进调色盘就创建了瓦片
📝Note
和SpriteShape差异
SpriteShape可以让地形具有弧度,适合制作曲线圆形等不规则形状的地图元素。
TileMap更适合由方块组成的平铺地图,可以通过层叠不同图层来快捷地制作有伪“Z”轴效果的地图,即模拟出三维空间的效果。
这一节更接近美术,在untiy中看着弄比较好。
创建TilePalette的时候,注意几个参数
- Grid网格布局,Rectangle方形地形(横版游戏);Hexagon六边形(可以选点朝上还是横线朝上,策略类游戏);Isometric等距瓦片(有“Z”轴效果的2D游戏);IsometricZAsY 转换Z轴为Y轴的等距瓦片(适用于不同类型的游戏,看一眼就大致明白了
- CellSize格子大小
滚轮中键 或 alt+左键 平移视角,放大缩小单选多选就不多说了,瓦片编辑器和PS一样,美术相关的不多说(就是魔兽地图编辑器一样的,都可以编辑。
调色盘的正确用法是,把TilePalette当调色盘用,把所有可能用到的瓦片拖进来一份,在Sence中Create->2D->Tilemap->选一个 就创建了一个Grid,然后用笔刷工具吸取自己想要的色块绘制Grid。
等距ZasY瓦片地图的排列是按照Sprite的轴心点定位的,有些素材不是中心贴合的。在等距瓦片Grid中默认是按照Z轴排序的,在ProjectSetting->Graphics->TransparencySortMode: Custom->TransparencySortAxis: (X,Y,Z)=(0,1,-0.26),设置Grid对象的TileMap中设置TileMap的Mode为Individual,然后高低差就正常了。在TilePalette的Brush中勾掉LockZPosition就可以使用 - +
键调整地形的高度,他使用Y轴模拟了高度。
Grid组件是网格的基本组件,参数一看就懂,注意网格大小<->Sprite PixelPerUnite对应,调整格子和Sprite的填充。
TileMapRenderer组件,SortOrder设置所选瓦片地图上的瓦片排序方向,带Z轴模拟的一般左上角,其他一般默认右下角就行;Mode渲染模式,chunk按位置分组批处理渲染瓦片,Individual单独渲染每个瓦片,带位置和排序顺序
选择需要添加Collider的TileMap层,然后添加TileMapCollider2D然后自动根据有Sprite的格子生成了Collider,注意格子生成瓦片使用的Collider格式。
轴心点排序: 参与等距网格的角色一般把Sprite的中心点设置为脚下(0.5X,Y),地图上的Sprite的SpriteSortPoint需要设为Pivot,轴心点排序,Sprite的轴心点如果在指定地图轴心点Y轴的上方那就会显示在前面。这样就会影响轴心点画地图不太推荐
排序层排序: 可以创建多个等距瓦片地形,给不同的OrderInLayer,形成植被景观等效果分层,但是不同层不会产生遮挡,1.这时候可以再创建一个层,比玩家层高绘制的东西就可以挡在玩家前面;2.如果是植物这种,把它和玩家放一个层都设置好轴心点排序,控制轴心点
一般在这种2D游戏中,不会使用重力和Rigidbody控制移动,而是使用Transform。伪Z轴碰撞的话,地形瓦片Sprite是不使用碰撞的, 墙体瓦片Sprite使用Gride碰撞,会约束玩家在格子里。如果碰撞在本层网格上看起来靠不到边,那就再在外面叠一层让它和外面的进行碰撞。
2.5D的跳跃动画可以创建一个空父物体,跳跃的时候让人物子物体在Y轴动。
TileMap拓展包
旧版本去github下载对应版本,然后拖进Assets导入;2020之后的版本直接在Window->PackageManager中安装
感觉这东西一时半会用不到,而且好多年没更新了,进行一个挖坑然后有空了再补TIleMap Extra
代码控制Tile
public Tilemap map; // 瓦片地图
public Grid grid; // 布局,可以有多层瓦片
public TileBase tile; // 瓦片
public TileBase new_tile;
void Start()
{
map.ClearAllTiles();// 直接清图
TileBase tile = map.GetTile(new Vector3Int(0,0,0)); // 获得这个map坐标上的Tile
map.SetTile(new Vector3Int(0,0,0), tile); // 把指定位置替换成这个瓦片,SetTiles可以设置多个,清理格子可以用null
map.SwapTile(tile,new_tile); // 把地上所有的 tile类型 替换成 new_tile类型
grid.WorldToCell(Vector3.zero); // 把指定的世界坐标转换为指定的格子的坐标
}
2D Collider和Rigidbody
先看一下参数,重点记和3D不同的参数。
前提条件和3D的碰撞体刚体一样,必须两者都有Collider至少一个有Rigidbody,2D的碰撞器在检测碰撞的时候只计算XY轴忽略Z轴。
2D Collider和Rigidbody都能设置物理材质,物理材质应用优先级是 2D Collider指定的材质>2D Rigidbody指定的材质>Physics2D窗口指定的物理材质>Unity默认的值。子物体如果没有设置材质,则会继承父物体Rigidbody指定的材质。
在ProjectSettings->Physics2D可以指定全局2D物理材质,一般的实践都是在Collider上指定,有一些子物体需要的时候才用Rigidbody指定
- Simulate默认选上否则会不使用物理系统。AutoMess就是自动计算质量,可能根据体积?一般是自己指定他算的不准。
- LinearDrag和AngularDrag,GravityScale就是 位移阻力系数 和 旋转阻力系数,重力系数,跟3D里面一样
- SleepingMode睡眠模式:StartAwake移动时唤醒(经常移动的单位),Start Asleep碰撞时唤醒(静止的单位)
Dyanmic(常规运动和碰撞的对象
Static(不动不受力但是需要碰撞检测的对象,碰撞器和所有的其他碰撞器检测,但它只能和Dyanmic刚体进行碰撞检测
Kinematic运动学类型 (通过API移动不受力的作用但是要进行碰撞检测
- 不受力的影响,只能通过代码让其动起来。
- 能和Dynamic 2D刚体产生碰撞,但是不会动,只会进入碰撞检测函数。
- 因此它没有了质量、摩擦系数等属性。
- 性能消耗较低,主要会通过代码来处理其移动旋转。
- Simulated
- 如果希望2D刚体以及所有子对象2D碰撞器和2D关节都能模拟物理效果,需要启用该选项。
- 当启用时,会充当一个无限质量的不可移动对象,可以和所有2D刚体产生碰撞。
- 如果Use Full Kinematic Contacts禁用,它只会和Dynamic 2D刚体碰撞。
- Use Full Kinematic Contacts
- 如果希望能和所有2D刚体碰撞,启用它。
- 如果不启用,它不会和Kinematic 2D和Static 2D刚体碰撞。
自带的碰撞器,粗糙点的,人物一般用胶囊碰撞器。
再就是边界碰撞器可以自己编辑;复合碰撞器会统合子对象所有的碰撞器作为统一的碰撞检测,复合碰撞器一定是要配合刚体使用,GeometryType可以规定碰撞器是不是实心的。
物理材质和3D中的材质参数一样,摩擦力和弹力系数,计算的时候是两个物体的材质系数一块计算的
Constant Force组件可以添加恒定力,效果类似于每帧AddForce,但是应用场景上不太一样。
效应器
首先需要在Collider中开启UsedByEffector并且勾选Trigger才能使用效应器
- AreaEffector2D 区域效应器 对此区域不断施加力和扭矩力,参数大部分一看就懂,注意UseGlobalAngle是否使用世界坐标;ForceTarget是按照 碰撞器中心 还是按照 刚体质心,按照刚体的话不会产生旋转力(下同;关闭是否启用碰撞器遮罩就会施加到所有层。
- BouyancyEffector2D 福利效应器 SurfaceLevel 是浮力的面在哪里,在线的下面会受到力的作用达到平衡。三个参数分别是 流体角度 流体速度 和 流体速度的波动范围
- PointEffector2D 点效应器 ForceMode:Constant 忽略距离施加力大小固定;Inverse Linear力的大小与距离成线性反比;Inverse Squared 模拟现实世界中的重力效果,力的大小与距离的平方成反比
- PlatformEffector2D 平台效应器 作用是让区域成为可以从下面跳上去但是上面能接住的平台(可以不使用Trigger
- UseOneWay是是否使用上半部分作为单向碰撞器
- UseOneWayGrouping如果有多个碰撞器勾选后会把所有的碰撞器都设置为单向碰撞器
- SurfaceArc包含的角度会被视为平台跳不上去
- UseSideFriction; UseSideBounce; SideArc 这三个规定的是两个侧边的面积角度,摩擦力和弹力(就是边上能扒住
- SurfaceEffector2D表面效应器(可以不使用Trigger
- Speed速度 SpeedVariation速度波动范围
- ForceScale 控制了沿表面移动时施加的力的系数。建议不要将其设置为1,因为这样可能会抵消其他作用在物体上的力
- UseContactForce是否在接触点的表面施加力,而不是在质心(可能导致旋转和倒伏
Warning
效应器的局限性
效应器其实也是写好代码的组件,本质还是施加力,如果功能有限不符合项目要求不要犹豫,自己写
2D动画
原理是固定时间间隔在一个Sprite数组中切换当前Sprite,就可以达到2D帧动画的效果。正常创建是把序列Sprite创建到Anime中,可以修改此anime的Inspector->Debug可以修改动画的帧率,记得如果修改帧率一定要把原先的动画帧删除再拖新的进去,否则可能会出现丢帧问题。创建的帧动画可以直接在状态机中使用。
其实这个anime的原理也是固定切换Sprite,它默认只能驱动SpriteRenderer,但是你可以把动画挂到UI的Image上然后在关键帧里直接更改属性拖进序列去就可以驱动UI播放帧动画。
2D骨骼动画
利用3D美术骨骼的原理进行制作,为每个部位绑骨骼,性能换内存节省资源。2018后用的方案是2D Animation,先在PackageManager安装。
在SpriteEditor->SkinningEditor编辑骨骼。编辑器中双击选中图片,PreviewPose不会真正影响骨骼,可以预览。 骨骼可以从根部创建,父骨骼动会带动子骨骼。
绑完骨骼就进行Geometry蒙皮,一般是先自动生成再手动调整细节,蒙皮的点越多越占用性能,也会越精细。
调整完蒙皮下一步是设置权重,蒙皮自动生成的时候自动会产生权重,即受骨骼影响大小
注意这几个参数
- Outline Detail(边缘细节)值越大,模型的轮廓就越细致。它影响模型边缘的清晰度和精细程度
- Alpha Tolerance(阿尔法公差值)控制蒙皮细节,通过调整它可以优化模型表面的透明度处理,确保在半透明或有复杂纹理区域的平滑过渡
最后给Scene中的Sprite添加SpriteSkin组件,生成一下骨骼就可以在动画中使用它了,图集的骨骼编辑好导入之后,先建立一个空的父对象,挂Sprite子对象,可以直接场景上编辑父子关系,2D对象一般把中心位置放在脚下。
psb文件和psd都是PS的原生文件格式,unity中推荐使用psb格式导入ps的素材需要先安装Window->PackageManager->2DPSDImporter
一般来说ImportHiddenLayers不勾选,隐藏图层美术留着不导入的;UseLayerGroup一般也不勾选
ImportMode默认Individual Sprites (Mosaic)每个图层会被当作独立的 Sprite 导入,MosaicPadding一般设置1-8不要设置0,在将多个图层合成一张 Mosaic 贴图时,它们之间的间隔像素数,防止图集边缘出现“图像溢出”或采样错误(通常在缩放、模糊、移动时容易出现)
反向动力学IK(Inverse Kinematics)正向动力学FK(Forward Kinematics)正向动力学就是父骨骼带动子骨骼摆动,2022.2后IK已经包含在2DAnimation里面
添加IK Solvers的模式三种 Chain (CCD) 可以自定义影响N个关节点,但不能反向;Chain (FABRIK) 同样可以自定义影响N个关节点,但与CCD不同的是,它可以反向计算;Limb 只会影响3个关节点。
为Sprite添加一个空的父对象,在这个父对象上挂载IK Manager2D组件,添加Solver,此时会在此父对象下生成一个Solver对象;在每个想要被IK控制的骨骼末端位置放置一个子对象。把末端的物体拖到生成的Solver的Effector上,生成的新对象就会牵引着骨骼动。
CCD模式不能直接180度往上拉,FABRIK可以。Limb一般给四肢用,位置不对可以Flip反向,使用此IK的时候,只改变身体的骨骼保持IK的点不动,可以方便的制作idel动画。使用IK点可以使动态动画瞄准某个地方或者动态的生成拾取等动画。
2D换装的实现,在psb中把对应位置的装备都做好,然后都隐藏,需要哪个显示哪个。在SpriteEditor->SkinngEditor->Visibility->Sprite中可以给同部位的装备打Category,一般设置同部位Category相同。
此时场景的psb的对象下SpriteLibrary关联了SpriteLibraryAssets记录类别分组信息,在对应要换装部位的物体上自动挂载SpriteResolver组件选择使用什么装备,换装会自动依赖骨骼。
2DSprite换装的API
public class TestSpriteResolver : MonoBehaviour
{
public SpriteResolver sr;
private Dictionary<string, SpriteResolver> equipDict = new Dictionary<string, SpriteResolver>();
void Start()
{
SpriteResolver[] srs = this.GetComponentsInChildren<SpriteResolver>(); // 递归查找所有部位的SpriteResolver
for (int i = 0; i < srs.Length; i++)
{
equipDict.Add(srs[i].GetCategory(), srs[i]); // 存储当前的 类别名:SpriteResolver组件
}
ChangeEquipment("Cask","CASK 1"); // 把CASK部位切换成CASK 1
}
void ChangeEquipment(string category,string item) // 封装一下
{
if (equipDict.ContainsKey(category))
{
equipDict[category].SetCategoryAndLabel(category,item);
}
}
}
图片都在一个psb下的时候,图集会很大,所以大的时候需要在不同psb中制作换装。在制作psb的时候,制作多套psb隐藏某些图层,某一套只保留本体,其他的多套只保留装备,换一套叠加就换了一套装备。
注意 不同文件的骨骼信息必须统一,所以直接复制骨骼到各个套装 PreviewPose选中需要复制的骨骼,然后CopyRig,不要关掉窗口选择新的psb PasteRig。就复制过去了。
手动创建SpriteLibraryAssets,编辑Inspector;给根对象添加SpriteLibrary组件,关联SpriteLibraryAssets文件;在想要换装的部位添加SpriteResolver,然后就可以看到换装类别了。代码上面的就能重复使用了。
当换装的内容特别多的时候,放一起图集会越来越大,有些移动设备不支持太大图集。
这一节的东西基本都是美术类的直接去Unity中摸的多了就会了,记的东西不多
Spine动画
收费跨平台主流2D动画方案,三个主流引擎都支持,官网在这。程序会用就行,不用制作。下载安装导入不多bb,动画都是用Spine制作完之后导出,然后扔到引擎中使用。
Spine导出的资源文件夹下(官网建议使用二进制导出因为性能更好)
.json
文件:骨骼结构动画数据等.png
文件:使用的图集.atlas.txt
图片在图集中的位置信息,定位和提取具体的贴图
拖入入Unity Assets后的自动生成:(确认引入了Spine运行库_Atlas
文件是材质和.atlas.txt文件的引用配置文件,用于管理图集资源_Material
材质文件_SkeletonData
骨骼动画文件,关联了.json文件和_Atlas资源。 最重要的是_SkeletonData
文件,拖到场景中就可以创建。注意_SkeletonData
的参数:- SkeletonDataJSON绑定的骨骼
- scale缩放比例默认0.01
- MixSettings可以设置动画之间的过渡时间和指定动画的过渡
- Slots相当于Spine动画的图层
- SkeletonMecanim生成新动画系统状态机,状态机展开就有AnimationClip用了
_SkeletonData
创建的选项,第一种是挂载SkeletonAnimation组件,第二是挂载SkeletonMecanim+Animator使用状态机控制。
SkeletonAnimation的代码控制动画提供了额外的API
public class SpineTest : MonoBehaviour
{
private SkeletonAnimation sa;
[SpineAnimation] // 标明下面的属性是存储动画的,在这Editor里指定
public string JumpAnimation;
// 与上面同理
[SpineBone] // 骨骼
public string boneName;
[SpineSlot] // 插槽
public string slotName;
[SpineAttachment] // 附件
public string attachName;
void Start()
{ sa = GetComponent<SkeletonAnimation>();
sa.loop = false; // 需要先停止动画循环
sa.AnimationName = "jump"; // 再进行动画绑定
// trackIndex 默认一般0就可以,也不知道为啥
sa.AnimationState.SetAnimation(0, JumpAnimation,false); // 不循环直接播放jump动画 和上面两句等效
sa.AnimationState.AddAnimation(0, "jump",false,0); // 不循环 延迟0s 添加junp到播放队列
sa.skeleton.ScaleX = -1; // X轴反转
sa.AnimationState.Start += (t) =>
{
Debug.Log("动画开始播放"+sa.AnimationName);
}; sa.AnimationState.End += (t) =>
{
Debug.Log("动画中断或清除"+sa.AnimationName);
}; sa.AnimationState.Complete += (t) =>
{
Debug.Log("动画播放完成"+sa.AnimationName);
}; sa.AnimationState.Event += (t,e) =>
{ // 此自定义事件 做动画的时候在Spine中定义的这里只用
Debug.Log("触发自定义事件"+sa.AnimationName);
};
Bone b = sa.skeleton.FindBone(boneName); // 可以控制骨骼旋转,牵引IK等
sa.skeleton.SetAttachment(slotName,attachName);// 设置插槽的附件,达成换装的效果
}
}
拖进去的时候使用SkeletoGraphic
就可以挂载UI的Canvas下在UI中使用了。
游戏管理
委托
#todo 这里以后要再学一学,C#中的委托和Unity中委托的异同点
Unity 额外提供了 UnityEvent
,它是 System.Delegate
的封装,主要用于与 Inspector(检查器)交互,支持序列化,可以在 Unity 编辑器中直接绑定回调 Unity 的事件生命周期管理 Unity 提供了 Start()
, Update()
, OnDestroy()
等生命周期方法,而 C# 传统的委托需要手动管理生命周期。GC(垃圾回收)影响 Unity 的 UnityEvent
是基于 List<Delegate>
而非 C# 的 MulticastDelegate
,因此不会像 C# 事件那样造成隐式的 GC 压力(捕获 this
时的内存泄漏风险较低)
📝Note
柯里化概念复习
柯里化(Currying)是一种将接受多个参数的函数转换成接受一个单一参数的函数,并且返回接受余下参数的函数的技术。这种技术由逻辑学家Haskell Curry提出,因此得名柯里化。
假设有一个函数 f(a, b, c),经过柯里化处理后,它会被转换成 f(a)(b)(c) 的形式。
反柯里化(Uncurrying)是柯里化的逆操作,它将嵌套的函数转换为一个接受多个参数的函数。反柯里化可以使得嵌套的单参数函数变得更易用,特别是在需要处理多个参数的情况下。
假设有一个柯里化函数 f(a)(b)(c),经过反柯里化处理后,它会被转换成 f(a, b, c) 的形式。
线程和协程
线程其实正常的起就可以,一般是会把密集的计算或异步IO,线程没法直接传递结果,一般会声明公共的缓冲区,由untiy周期函数尝试去拿,拿到了就是计算完了。
启动一个协程函数(即带有 IEnumerator
返回类型的函数), 必须通过 StartCoroutine
直接调用只会执行方法中的同步部分 ,而不会处理 yield return
语句或等待异步操作完成。
其实就可以直接搞生产消费者那套了
Warning
线程一定要注意
千万别Join,Unity编辑器本质也是通过反射调用的C#,如果在生命周期里Join整个程序会卡住。
所以线程用完一定要关闭!!!否则线程就会一直开启直到进程关闭
子线程无法访问Unity主线程内容中的东西
简单多线程演示
void Start()
{
Thread t = new Thread(() => TestThread());
t.Start();
}
private void TestThread()
{
int count = 0;
while (true)
{
count += 1;
Thread.Sleep(1000);
Debug.Log($"TestThread print {count}");
this.GetComponent<Collider>();// 这里尝试访问住线程的对象报错了!
}
}
也可以使用线程池,频繁创建删除线程时提高性能节约内存,但不能控制线程池中线程的执行顺序,也不能获取线程池内线程取消/异常/完成的通知
Task是基于线程池的改进。 #todo 练习使用 C#多进程 Unity中和C#中的线程池 Task async和await 进阶课3C系列知识14-17集 Unity中的协程通常在一段时间内执行某些任务或在帧之间分配工作,在Update()⽅法之后LateUpdate()⽅法之前调用协程通过 IEnumerator
接口和 StartCoroutine
方法来实现。
- 返回值必须为
IEnumerator
接口类型,使用yield return
。 - 是非阻塞的,适合需要在多个帧之间执行的任务。
- 依赖于Unity的更新循环,无法脱离Unity环境使用。
- 脚本 物体销毁,物体失活 协程不执行。脚本如果已经运行了协程然后再失活协程仍会执行。
协程测试
void Start()
{
Coroutine cor=StartCoroutine(AsyncDemo(10)); // 去执行一个协程
StopCoroutine(cor); // 结束此协程调用
StopAllCoroutines(); // 停止掉此脚本此脚本此脚本协程池里面所有协程
// StartCoroutine(AsyncDemo(10)); // 也可以这样,但是这样不知道在哪里用了几次。
// 所以建议直接不要这样用,做一件事最好的方法有且仅有一个
}
public IEnumerator AsyncDemo(int num) // 协程的固定返回类型
{
Debug.Log("wait for return ");
yield return new WaitForSeconds(3f); // 3s之后返回,有很多不同的返回函数,用到再看
Debug.Log("done return 1s");
yield return null;
Debug.Log($"done next return , out {num}");
transform.position = new Vector3(10, 10, 10); // 做一次位移
yield return AsyncDemo2(); // 协程是可以嵌套的
}
public IEnumerator AsyncDemo2() // 利用协程做动画,以及协程嵌套,协程本质就是一个函数栈暂停,控制权移交
{
Debug.Log("start async2");
while (true)
{
transform.Rotate(new Vector3(10,0,0)); // 调整角度,配合协程达到旋转的效果,不过这个旋转不是平滑的
// 这里可以用while true执行
yield return new WaitForSeconds(0.5f); // 0.5秒返回一次
}
}
运行结果
[22:40:01] wait for return
[22:40:04] done return 1s
[22:40:04] done next return, out 10
[22:40:04] start async2
yield返回的特殊方法
yield return null/数字
和yield return WaitForSeconds
:在满足条件后的下一帧Update
和LateUpdate
之间执行yield return WaitForFixedUpdate
:在FixedUpdate
和碰撞检测相关函数之后执行(物理更新后)yield return WaitForEndOfFrame
:在摄像机和GUI渲染完成后FixedUpdate
之后执行yield break
:立即结束协程,协程对象会变null
- 其他的异步对象(网络 场景)一般是
Update
和LateUpdate
之间执行
协程的本质是迭代器,然后Unity调度器执行,和py里面迭代器一样,使用Current和MoveNext执行,直接点进IEnumerator
看。
自带的Coroutine无法进行trycacth,必须依赖于Monobehavior。使用C#原生的的async会跨线程而且Task消耗也不小,但是可以摆脱对MonoBehavior的依赖。
📌Tip
Task的本质上是起了新线程,Task.Delay完全不受TimeScale影响。
延迟函数
MonoBehaviuor中提供了延迟调用函数的方法Invoke
InvokeRepeating
CancelInvoke
(参数都简单易懂
延时执行只能无参,而且只能执行本脚本中的对象,但是可以先获得,再通过函数或者Action包一下执行。Invoke
即为调用,Invoke
多次会调用多次,但是CancelInvoke
是按照函数签名去取消的,也就是说会把该函数的调用全都踢出队列取消掉。
注意 对象或脚本失活不会影响延迟函数调用;但是对象或脚本销毁移除,就延迟函数不会执行了 如果想要用失活激活控制,可以放到Enable和Disable中控制
延迟函数
void CurryingDelyDemo(string s)=>Debug.Log($"be Invoked {s}");
public void InvokeDemo()=>Debug.Log("be Invoked");
public void CalcelInvokeDemo()
{
Debug.Log("Invoke is Canceled");
CancelInvoke("InvokeDemo");
}
void Start() // GameObject加载完毕后所有的Awake执行完,的第一帧执行一次
{
Action myInvokeDemo = () => CurryingDelyDemo("Hello, World!");
Debug.Log("Object start successfully Complete");
// 定时任务
Invoke("InvokeDemo",3f); // 3秒(浮点)后通过反射来执行函数
InvokeRepeating("InvokeDemo",2f,3f); // 从调用的2秒后每3s执行一次
Invoke("CalcelInvokeDemo",10f); //10秒(浮点)后通过反射来执行取消
Invoke(myInvokeDemo.Method.Name,3f); // 通过委托的闭包传递参数
}
Unitask
常用的替代原生的异步任务库,其实是第三方插件。0GC,集合了原生async和unityCoroutine的优点。虽然异步的原理都是状态机但是实现的结构不太一样。看文档安装GitHub - Cysharp/UniTask
Debug模式下Unitask的起停性能差不多比原生协程强40%-50%,Release模式是原生协程的8-10倍左右。
unitask使用await等待dotween动画需要开启一个宏UNITASK_DOTWEEN_SUPPORT。
📌Tip
首先来嘱咐一下异步相关的问题,无论是使用任意一种异步方法,实际上也是在主线程中执行的,async实际上只是标识了可以使用异步方法,但是如果在async中调用同步方法依旧会阻塞主线程。所以建议使用await异步方法
我看的是游戏石匠的视频和代码,用法很全面了,不过最好去执行一下才能理解。
基础用法
原生async转换为unitask
using System;
using System.Collections;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using Scenes.TestUnitask;
using TMPro;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class UnitaskT1 : MonoBehaviour
{
public Button LoadTextButton;
public Text TargetText;
public Button LoadSceneButton;
public Slider LoadSceneSlider;
public Text ProgressText;
public Button WebRequestButton;
public Image DownloadImage;
void Start()
{
LoadTextButton.onClick.AddListener(OnClickLoadText); // 签名匹配才能直接传
LoadSceneButton.onClick.AddListener(OnClickLoadScene);
WebRequestButton.onClick.AddListener(OnClickWebRequest);
}
private async void OnClickLoadText()
{ // 在MonoBehaviour中使用加载
// ResourceRequest loadOper = Resources.LoadAsync<TextAsset>("test");
// await loadOper.ToUniTask();
// TextAsset _text = loadOper.asset as TextAsset; // 这里返回的是一个异步对象不能直接转换
// TargetText.text = _text.text;
// 分离调用类可以在非MonoBehaviour中使用
UniTaskAsyncSample_Base asyncUnitaskLoader = new UniTaskAsyncSample_Base(); // todo 这里其实不是特别懂
TargetText.text = ((TextAsset)(await asyncUnitaskLoader.LoadAsync<TextAsset>(path: "test"))).text;
}
private async void OnClickLoadScene() // 异步加载场景传出进度,写法比价固定
{
await SceneManager.LoadSceneAsync("NextScene").ToUniTask(
Progress.Create<float>(
handler: (p) => {
LoadSceneSlider.value = p;
if (ProgressText != null)
{
ProgressText.text = $"读取进度{p * 100:F2}%";
}
})); // UniTask
}
private async void OnClickWebRequest() // 从网络上下载一个序列帧,切分并且0.1s为间隔播放
{
var webRequest =
UnityWebRequestTexture.GetTexture(
uri: "https://s1.hdslb.com/bfs/static/jinkela/video/asserts/33-coin-ani.png");
var result = (await webRequest.SendWebRequest());
var texture = ((DownloadHandlerTexture)result.downloadHandler).texture;
int totalSpriteCount = 24;
int perSpriteWidth = texture.width / totalSpriteCount;
Sprite[] sprites = new Sprite[totalSpriteCount];
for (int i = 0; i < totalSpriteCount; i++)
{
sprites[i] = Sprite.Create(
texture, // 用什么创建
new Rect(new Vector2(perSpriteWidth * i, 0), new Vector2(perSpriteWidth, texture.height)), // 位置大小
new Vector2(0.5f, 0.5f)); // 中心点锚点
}
float perFrameTime = 0.1f;
while (true)
{
for (int i = 0; i < totalSpriteCount; i++)
{
await UniTask.Delay(TimeSpan.FromSeconds(perFrameTime)); // 因为是在异步中不会卡死主线程
var sprite = sprites[i];
DownloadImage.sprite = sprite;
}
}
}
}
不托管MonoBehaviour的方法
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;
using Object = UnityEngine.Object;
namespace Scenes.TestUnitask
{
public class UniTaskAsyncSample_Base
{
public async UniTask<Object> LoadAsync<T>(string path) where T : Object
{
var asyncOperation = Resources.LoadAsync<T>(path);
return (await asyncOperation);
}
}
}
等待时机执行
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.LowLevel;
using UnityEngine.UI;
namespace UniTaskTutorial.BaseUsing.Scripts
{
public class UniTaskWaitTest : MonoBehaviour
{
public PlayerLoopTiming TestYieldTiming = PlayerLoopTiming.PreUpdate;
public Button TestDelayButton;
public Button TestDelayFrameButton;
public Button TestYieldButton;
public Button TestNextFrameButton;
public Button TestEndOfFrameButton;
public Button ClearButton;
public Text ShowLogText;
private List<PlayerLoopSystem.UpdateFunction> _injectUpdateFunctions = new List<PlayerLoopSystem.UpdateFunction>();
private UniTaskAsyncSample_Wait unitaskWaiter;
private bool _showUpdateLog = false;
private void Start()
{
TestDelayButton.onClick.AddListener(OnClickTestDelay);
TestDelayFrameButton.onClick.AddListener(OnClickTestDelayFrame);
TestYieldButton.onClick.AddListener(OnClickTestYield);
TestNextFrameButton.onClick.AddListener(OnClickTestNextFrame);
TestEndOfFrameButton.onClick.AddListener(OnClickTestEndOfFrame);
ClearButton.onClick.AddListener(OnClickClear);
unitaskWaiter = new UniTaskAsyncSample_Wait();
InjectFunction();
}
private async void OnClickTestDelayFrame()
{
Debug.Log($"执行DelayFrame开始,当前帧{Time.frameCount}");
await UniTask.DelayFrame(5);
Debug.Log($"执行DelayFrame结束,当前帧{Time.frameCount}");
}
private async void OnClickTestDelay()
{
Debug.Log($"执行Delay开始,当前时间{Time.time}");
await UniTask.Delay(TimeSpan.FromSeconds(1)); // 第二个参数ignoreTimeScale如果为true可以忽略游戏时间缩放
Debug.Log($"执行Delay结束,当前时间{Time.time}");
}
private void OnClickClear()
{
ShowLogText.text = "Log:";
}
private void InjectFunction()
{
PlayerLoopSystem playerLoop = PlayerLoop.GetCurrentPlayerLoop();
var subsystems = playerLoop.subSystemList;
playerLoop.updateDelegate += OnUpdate;
for (int i = 0; i < subsystems.Length; i++)
{
int index = i;
PlayerLoopSystem.UpdateFunction injectFunction = () =>
{
if (!_showUpdateLog) return;
Debug.Log($"执行子系统 {_showUpdateLog} {subsystems[index]} 当前帧 {Time.frameCount}");
};
_injectUpdateFunctions.Add(injectFunction);
subsystems[i].updateDelegate += injectFunction;
}
PlayerLoop.SetPlayerLoop(playerLoop);
}
private void UnInjectFunction()
{
PlayerLoopSystem playerLoop = PlayerLoop.GetCurrentPlayerLoop();
playerLoop.updateDelegate -= OnUpdate;
var subsystems = playerLoop.subSystemList;
for (int i = 0; i < subsystems.Length; i++)
{
subsystems[i].updateDelegate -= _injectUpdateFunctions[i];
}
PlayerLoop.SetPlayerLoop(playerLoop);
_injectUpdateFunctions.Clear();
}
private void OnUpdate()
{
Debug.Log($"当前帧{Time.frameCount}");
}
private void OnDestroy()
{
UnInjectFunction();
}
private void OnEnable()
{
Application.logMessageReceivedThreaded += ShowLog;
}
private void OnDisable()
{
Application.logMessageReceivedThreaded -= ShowLog;
}
private void ShowLog(string condition, string stacktrace, LogType type)
{
ShowLogText.text = $"{ShowLogText.text}\n{condition}";
}
private async void OnClickTestEndOfFrame()
{
_showUpdateLog = true;
Debug.Log($"执行WaitEndOfFrame开始");
await unitaskWaiter.WaitEndOfFrame(this);
Debug.Log($"执行WaitEndOfFrame结束");
_showUpdateLog = false;
}
private async void OnClickTestNextFrame()
{
_showUpdateLog = true;
Debug.Log($"执行NextFrame开始");
await unitaskWaiter.WaitNextFrame(); // 从这一帧的PreLateUpdate到下一帧的Update之间等待
Debug.Log($"执行NextFrame结束");
_showUpdateLog = false;
}
private async void OnClickTestYield()
{
_showUpdateLog = true;
Debug.Log($"执行yield开始{TestYieldTiming}");
await unitaskWaiter.WaitYield(TestYieldTiming); // 从这一帧的PreLateUpdate到下一帧的自定义声明周期函数时执行
Debug.Log($"执行yield结束{TestYieldTiming}");
_showUpdateLog = false;
}
}
}
封装的等待类
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace UniTaskTutorial.BaseUsing.Scripts
{
public class UniTaskAsyncSample_Wait // 这里其实不封装也能用的
{
public async UniTask<int> WaitYield(PlayerLoopTiming loopTiming)
{
await UniTask.Yield(loopTiming);
return 0;
}
public async UniTask<int> WaitNextFrame()
{
await UniTask.NextFrame();
return Time.frameCount;
}
public async UniTask<int> WaitEndOfFrame(MonoBehaviour behaviour)
{
await UniTask.WaitForEndOfFrame(behaviour);
return Time.frameCount;
}
}
}
使用UniTask的WhenAll和WhenAny监听,WaitUnitl对象挂载一次就只能使用一次,需要重复监听的话就得重新挂载。
C#设计async和await的时候并不希望我们去取消所以支持很差。Unitask中有取消,可以用抛出异常或者额外返回值的方式取消,用额外返回值的方式性能更好。
使用Token取消的时候Cancel之后不Dispose这个Token就不能复用了。
拓展用法
进阶用法
内置资源
特殊文件夹
Application.dataPath
是总的工程目录文件夹Assets
的路径,在构建之后并不直接暴露给应用程序不能直接访问,一般只在开发阶段用用,也不保证跨平台一致性Resources
资源文件夹直接使用Resources
类读取资源。Resources可以在任意层级任意地方,但必须在Assets文件夹下,多个Resources会被合并起来。- 当不同Resources中文件重名,会加载第一个找到的文件,如果有文件夹重名,则会报警告提示重复的资源路径 。在Start中使用
prefab_cube = Resources.Load<GameObject>("Perfabs/s2");
即可加载(非实例化仅加载对象到内存)。 - 适用于需要经常在运行时加载并实例化的资源(如场景、预制件、材质等)Unity会自动打包引入的资源文件,但是这个文件夹里的会全都加载。
- 不要存配置文件、日志文件等需要原样读取的文件,因为 Unity 会自动处理和打包,打包完后只读
- 无法被压缩和热更,所以现代商业项目一般不放东西,被那一堆资源管理取代了,不热更的小项目或者MVP可以用
BillingMode.json
Unity内购API自动生成的配置文件,构建时指定目标应用商店,以便在运行时加载正确的支付插件。Editor中使用UnityPurchasingEditor.TargetAndroidStore()
选择目标商店时,Unity 会自动生成或更新该文件
- 当不同Resources中文件重名,会加载第一个找到的文件,如果有文件夹重名,则会报警告提示重复的资源路径 。在Start中使用
- 位于
Assets/StreamingAssets
,使用Application.streamingAssetsPath;
获得- 移动平台只读PC可读写
- 常用
string path = Path.Combine(Application.streamingAssetsPath, "netConfig.json");
读取其中资源 - 跨平台一致性 文件夹中的内容在所有平台(Windows、macOS、iOS、Android等)上保持一致
- 文件保持原样 Unity 不会对
StreamingAssets
中的文件进行压缩或优化,适合存放媒体、配置文件、游戏内语言包、地图数据等静态数据
persistentDataPath
持久数据文件夹Application.persistentDataPath
获得使用在运行时可读写,动态下载或者游戏中生成的东西存储,游戏存档经常放里面Plugins
插件文件夹 放第三方SDK的Editor
编辑器文件夹 使用Application.dataPath+"/Editor"
拼接,一般不获得;开发编辑器的时候脚本放里面;不会被打包using UnityEditor
被打包到runtime的时候会报错
StandardAssets
默认资源文件夹 Unity自带的文件都放这个文件夹,代码和资源优先被编译,一般不获得。
访问资源
UnityWebRequest不仅可以请求web还可以请求本地文件,但路径必须带file://
,Win/Mac编辑器里运行时对本地绝对路径做了兼容,最佳实践就是直接全加上file://
。
实际上在Android和iOS的streamingAssetsPath被原样打包进了包里,用unity才能读到其他方式没权限拿包里的东西。
读取本地路径 | persistentDataPath | persistentDataPath | streamingAssetsPath | streamingAssetsPath |
---|---|---|---|---|
读取方式 | UnityWebRequest | C#文件读取 | UnityWebRequest | C#文件读取 |
PC | 加 file:/// | 无需处理 | 加 file:/// | 无需处理 |
Android | 加 file:/// | 无需处理 | 加 jar:file:// | 不可用 |
iOS | 加 file:/// | 无需处理 | 加 file:/// | 无需处理 |
再就是使用AB包的方式,放到任意文件系统能访问的位置即可加载。
使用 Resources.Load<T>("Name",[typeof(TextAsset)])
同步加载资源,也可以指定类型或者传泛型。默认加载出来的类型为Unity.Object
可能需要类型转换
Warning
注意文件名
资源文件不要带文件后缀名,最好也不要带特殊字符,而且文件名是大小写敏感的
常见类型
- 预设体GameObject
- 音效AudioClip
- 文本TextAsset
- 图片Texture
- 材质Material
Resources
加载到内存后会存在缓存区,多次加载不会重复加载,只会重复在缓存区中查找也会损耗一点性能。
异步加载Resources.LoadAsync
会新起一个线程进行加载,至少要等到下一帧才能拿到结果。
异步加载和使用协程异步加载
Texture TargetTexture;
void Start()
{
/* 使用线程加载 */
// ResourceRequest Rq = Resources.LoadAsync<Texture>("Picture/t"); // 该类型包含Action对象
// Rq.completed += LoadDone; // 加载完之后通过回调通知执行
/* 使用协程加载 */
Coroutine cor=StartCoroutine(LoadTexture());
}
private void LoadDone(AsyncOperation op)
{
TargetTexture = (op as ResourceRequest).asset as Texture;
print("Loading Done");
}
IEnumerator LoadTexture()
{
ResourceRequest Rq = Resources.LoadAsync<Texture>("Picture/t");
yield return Rq; // 协程调度器会监控 Rq.isDone 的状态 Rq.priority,完事才执行下面的
while (!Rq.isDone) // 也可以手动监控
{
print($"porcessing {Rq.priority}");
yield return Rq;
} TargetTexture = Rq.asset as Texture;
}
public void OnGUI()
{
if (TargetTexture != null)
{ print("Draw texture");
GUI.DrawTexture(new Rect(0,0,200,100), TargetTexture);
}
}
资源必须是通过 Resources.Load
加载的才能卸载,使用Resources.UnloadAsset(oldTexture);
卸载,但是不能卸载GameObject对象即使没有实例化。
使用Resources.UnloadUnusedAssets
卸载所有未被引用的资源,此操作是异步的,会遍历所有资源并检查引用计数性能消耗很高,一般搭配GC.Collect()
在过场景的时候使用。 某个资源正在被场景中的对象使用(例如一个纹理正被某个材质使用),卸载它可能导致渲染异常或崩溃
加载场景
场景使用静态类UnityEngine.SceneManagement.SceneManager
,需要引入命名空间。能够直接加载的场景需要提前添加到 菜单->File->BuildSettings->Scenes in Build 中,注意这个里面场景有序号。
然后就可以直接SceneManager.LoadScene("NextScene")
加载场景(即便BuildSetting中是带路径的也只用名字就行),一般最常用的就是通过名字加载,GameObject.DontDestroyOnLoad(gameObject)
挂载者自己在场景切换的时候不销毁
退出游戏使用Application.Quit()
,打包发布出去才行,编辑模式下没用。(Application类可以获得很多和游戏相关的属性,比如游戏是否在运行isPlaying
加载的时候Unity会删除本场景上的所有对象然后读取下一个场景的东西,东西比较多的时候就耗时多。
异步加载场景SceneManager.LoadSceneAsync("NextScene")
返回值和资源异步加载一样是AsyncOperation
,可以使用complete事件绑定回调。常用来加载空场景然后使用指定的地图编辑器配置文件生成新场景的内容
异步加载场景的两种方法
void Start()
{
AsyncOperation loading=SceneManager.LoadSceneAsync(1);
/* 注意这里,理论上这里加载完后就销毁,但是这个函数被回调引用,引用计数不会销毁它,所以能继续执行 */
loading.completed += (AsyncOperation a) => print("场景2加载完毕");
}
IEnumerator LoadScene2()
{
print("Loading Scene 2");
yield return SceneManager.LoadSceneAsync(1);
/* 这样下面的语句就会执行,否则场景销毁对象也已经被销毁这里就不会被执行。 */
GameObject.DontDestroyOnLoad(this.gameObject);
print("Loading Scene 2 done");
/* 这里就可以加过渡和进度条等,按照你想的进度去做 */
}
private float time = 0;
void Update()
{
time += Time.deltaTime;
if (time > 2)
{
StartCoroutine(LoadScene2());
}
}
设置鼠标
鼠标使用公共类Cursor
管理,如果你不想鼠标变形就找个一样大小的图片,最好把这个素材的Inspector面板的类型设置为Cursor
Cursor.visible = false; // 显不显示
Cursor.lockState = CursorLockMode.Locked; // None不限制,Locked锁在屏幕中心还会被隐藏,Confined缩在游戏窗口
Cursor.SetCursor(cursor_texture, Vector2.zero, CursorMode.Auto); // 一个Texture2d 偏移量 自动管理。
持久化
PlayerPrefs
持久话键值对,{string:[int string float]}
默认是游戏结束才落盘,如果崩溃就丢了,其实就是个类型支持不全的字典。
存到哪,在BuildSettings->PlayerSettings->CompanyName;ProductName设置。
- win在注册表
\HKCU\Software\[CompanyName]\[ProductName]
- 安卓
/data/data/包名/shared_prefs/pkg-name.xml
- IOS
/Library/Preferences/[应用ID].plist
PlayerPrefs的基本API
void Start()
{
PlayerPrefs.SetInt("Test1", 10);
PlayerPrefs.SetFloat("Test2",0.5f);
PlayerPrefs.SetString("Test3","ttttt");
PlayerPrefs.Save();// 落盘
PlayerPrefs.DeleteKey("Test1");
// 如果读取的时候对应的类型错误 或者没有这个键,则会返回读取类型的默认值
print(PlayerPrefs.GetInt("Test1", 1)); // get的时候和dict一样可以获取默认值
print(PlayerPrefs.GetFloat("Test2",1f));
print(PlayerPrefs.GetString("Test3","NoValue"));
PlayerPrefs.DeleteAll(); // 全清除,测试常用
if(PlayerPrefs.HasKey("Test1"))print("Test1 有东西!");
}
这几个不是Unity自带的,C#直接看的书,有关序列化的坑也在这里了。
JSON
主流的这四个newtonsoft.json
, System.Text.Json
,fastjson
, jsondotnet
,Unity自带的JsonUtility
(非常不好用,只看第一个就行剩下俩碰到再说
直接上代码
基本用法
using System;
using System.Collections.Generic;
using System.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace PlayGround;
#region NewtonSoft.Json基础用法
class Student
{
public int Id { get; set; }
public string? FullName { get; set; }
public int Age { get; set; }
public string? Address { get; set; }
public double GPA { get; set; }
}
public class Play1
{
public string S_string = """
{
"Id": 1,
"FullName": "Jhon",
"Age": 20,
"Address": "翻斗花园",
"GPA": 3.5
}
""";
private JsonSerializerSettings _settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented, // 使用缩进
ContractResolver = new DefaultContractResolver
{
// NamingStrategy = new DefaultNamingStrategy()
NamingStrategy = new SnakeCaseNamingStrategy() // 蛇形
// NamingStrategy = new CamelCaseNamingStrategy()// 大驼峰
// NamingStrategy = new KebabCaseNamingStrategy() // 烤-串-命-名
},
NullValueHandling = NullValueHandling.Ignore,// 序列化遇到空值会跳过去掉这个属性
StringEscapeHandling = StringEscapeHandling.EscapeNonAscii, // 会把非ascii进行unicode编码
TypeNameHandling = TypeNameHandling.Auto,// 使用All可以强制带上序列化和反序列化的类型名。
};
public void Run()
{
Student s = new Student
{
Id = 1,
FullName = "Jhon",
Age = 20,
Address = "翻斗花园",
GPA = 3.5
};
string jsonStr = JsonConvert.SerializeObject(s); // 默认是一行压缩展示
jsonStr=JsonConvert.SerializeObject(s,_settings); // 自动换行
Console.WriteLine(jsonStr);
Student s1 = JsonConvert.DeserializeObject<Student>(S_string); // 设置序列化和反序列化都能用
Console.WriteLine(s1.FullName);
}
}
#endregion
#region JsonPopulateObject用法
public class Person2
{
public string Name { get; set; }
public int Age { get; set; }
public List<string> Hobbies { get; set; }
public override string ToString() => $"Name:{Name},Age:{Age},Hobbies:{string.Join(",", Hobbies)}";
}
public class Play2
{
private JsonSerializerSettings _settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented, // 使用缩进
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy() // 蛇形
},
NullValueHandling = NullValueHandling.Ignore,// 序列化遇到空值会跳过去掉这个属性
StringEscapeHandling = StringEscapeHandling.EscapeNonAscii, // 会把非ascii进行unicode编码
ObjectCreationHandling = ObjectCreationHandling.Replace // 使用PopulateObject如果是单独项则直接替换。如果是list就会append进来,指定Replace也会直接替换
};
private string s="""
{
"name": "Jack",
"age": 35,
"hobbies": [
"Chinese",
"English",
"Write"
]
}
""";
public void Run()
{
Person2 p = new Person2
{
Name = "Jack",
Age = 20,
Hobbies = new List<string> { "Play", "Read", "Write" }
};
Console.WriteLine(JsonConvert.SerializeObject(p,_settings));
JsonConvert.PopulateObject(s, p,_settings);// 读取json字符串,然后更新当前的对象
Console.WriteLine(JsonConvert.SerializeObject(p,_settings));
}
}
#endregion
#region 提供的Attributes
// MemberSerialization.OptIn 白名单模式,写了JsonProperty的才要
// MemberSerialization.OptOut 黑名单模式,写了JsonIgnore的才不要
// MemberSerialization.Fields 很奇怪,会把封装到IL的真实属性带出来?
[JsonObject(MemberSerialization.Fields),]
public class Person3
{
[JsonProperty("create_at1")] // 我就想序列化,还指定自定义名称!
private readonly DateTime CreateAt = DateTime.Now;
public Guid Id { get; set; }
public string Name { get; set; }
public int Age { get; set; }
public List<string> Hobbies { get; set; }
public event EventHandler? OnPropertyChanged;
[JsonIgnore] // 如果我不想序列化
public string? Description => $"Name:{Name},Age:{Age},Hobbies:{string.Join(",", Hobbies)}";
}
public class Play3
{
Person3 p = new Person3
{
Id = Guid.NewGuid(),
Name = "Jhon",
Age = 10,
Hobbies = new List<string> { "Funk", "Music" }
};
private JsonSerializerSettings _settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented, // 使用缩进
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy() // 蛇形
},
StringEscapeHandling = StringEscapeHandling.EscapeNonAscii, // 会把非ascii进行unicode编码
};
public void Run()
{
Console.WriteLine(JsonConvert.SerializeObject(p,_settings));
}
}
#endregion
#region 自定义的Converter序列化器
class Person4
{
[JsonProperty, JsonConverter(typeof(DateTimeFormateConverter))]
private readonly DateTime CreateAt = DateTime.Now;
public Guid Id { get; set; }
public string Name { get; set; }
public IntWapper _Age { get; set; } // 直接用会相当于嵌套对象一样的效果"_Age":{"Value":10}
// public int Age { get=>_Age.Value; set=>_Age.Value = value; } // 使用自定义的JsonConverter可以不用单独写这东西
[JsonConverter(typeof(StringEnumConverter))] // 可以让序列化出来的Enum使用名字而不是数字
public Gender Gender { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public WorkDays WorkDays { get; set; }
public List<string> Hobbies { get; set; }
}
public class Play4
{
Person4 p=new Person4()
{
Id = Guid.NewGuid(),
Name = "Jhon",
_Age = new IntWapper(10),
Gender = Gender.Male,
WorkDays = WorkDays.Monday |WorkDays.Tuesday|WorkDays.Wednesday|WorkDays.Saturday|WorkDays.Friday, // 使用二进制表示哪天干活
Hobbies = new List<string> { "Funk", "Music" }
};
public void Run()
{
string j = JsonConvert.SerializeObject(p,Formatting.Indented);
Console.WriteLine(j);
}
}
enum Gender
{
Male,
Female
}
[Flags] // 让一个枚举值可以同时表示多个状态的组合。序列化和 ToString() 时,自动输出组合名称。
public enum WorkDays
{
Monday=1,
Tuesday=2,
Wednesday=4,
Thursday=8,
Friday=16,
Saturday=32,
Sunday=64
}
class DateTimeFormateConverter : IsoDateTimeConverter // 自定义时间的序列化
{
public DateTimeFormateConverter()
{
DateTimeFormat = "yyyy-MM-dd";
}
}
[JsonConverter(typeof(IntWapperConverter))] // 所有使用IntWapper类的属性在自动序列化的时候都会走自定义的Converter
class IntWapper
{
public IntWapper(int value)
{
this.Value = value;
}
public int Value { get; set; }
}
class IntWapperConverter : JsonConverter
{
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
serializer.Serialize(writer, ((IntWapper)value).Value); // 序列化的时候怎么做
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
return new IntWapper(Convert.ToInt32(reader.Value)); // 反序列化的时候怎么做
}
public override bool CanConvert(Type objectType)
{
return typeof(IntWapper).IsAssignableFrom(objectType); // 能不能被序列化
}
}
#endregion
#region 序列化的HOOK函数
public class Person5
{
public string Name { get; set; }
public int Age { get; set; }
public List<string> Hobbies { get; set; }
public event EventHandler? OnPropertyChanged;
[OnSerializing]
internal void OnSerializingMethod(StreamingContext context)
{
}
[OnSerialized]
internal void OnSerializedMethod(StreamingContext context)
{
}
[OnDeserializing]
internal void OnDeserializingMethod(StreamingContext context)
{
Console.WriteLine("Deserializing!");
Hobbies?.Clear(); // 反序列化的时候先把原来的列表清掉
}
[OnDeserialized]
internal void OnDeserializedMethod(StreamingContext context)
{
this.OnPropertyChanged+= (sender, e) => Console.WriteLine("属性变化了");
this.OnPropertyChanged.Invoke(this, new EventArgs()); // 调用一下试试
}
}
public class Play5
{
Person5 p = new Person5
{
Name = "Jhon",
Age = 10,
Hobbies = new List<string> { "Funk", "Music" }
};
private string s = """
{
"name": "ahon",
"age": 15,
"hobbies": [
"Chinese",
"Math"
]
}
""";
private JsonSerializerSettings _settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented, // 使用缩进
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy() // 蛇形
},
StringEscapeHandling = StringEscapeHandling.EscapeNonAscii, // 会把非ascii进行unicode编码
};
public void Run()
{
Console.WriteLine(JsonConvert.SerializeObject(p,_settings));
JsonConvert.DeserializeObject<Person5>(s,_settings); // 这里应该就会输出日志并且挂上了那个是函数事件
}
}
#endregion
public class Play6
{
private string s = @"
[{
""$schema"": ""http://json.schemastore.org/launchsettings.json"",
""profiles"": {
""Newtonsoft.Json.Tutorial"": {
""commandName"": ""Project"",
""environmentVariables"": {
""ASPNETCORE_ENVIRONMENT"": ""Development"",
""ASPNETCORE_URLS"": ""http://localhost:5000"",
""DOTNET_USE_POLLING_FILE_WATCHER"": ""true""
}
}
}
}]";
public void Run()
{
// var obj = (JObject)JsonConvert.DeserializeObject(s); // 可以一般序列化不指定类型,但是注意Array还是Object
var obj = JArray.Parse(s); //
Console.WriteLine(obj[0]["profiles"]["Newtonsoft.Json.Tutorial"]["commandName"].ToString());
}
}
newtonsoft.json官网 需要注意和说明的参数
- 默认只序列化
public
的属性,他不会自动序列化字段,也就是说public int Id;
是不行的,要在类上给它加[JsonObject(MemberSerialization.Fields)]
或者在字段上加[JsonProperty]
- 假设我有Employee和Student继承自Person,此时使用
List<Person>
作为反序列化的泛型,会报错,因为他不知道该使用哪个类,此时用TypeNameHandling = TypeNameHandling.Auto
设置去反序列化带$type的json即可识别。如果不开启TypeNameHandling
属性他会退化成只填充了Person
有的字段(如Name
)其余字段(如Company
或School
)会自动忽略就不报错了。 - 处理无限嵌套或者不规则的json,互相嵌套的类(两个类中有属性是对方的类型)序列化失败,使用
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
, - 不明白的就拿出Run来跑一跑调一调
unity中打开Window->PackageManager
中Add package from name
使用com.unity.nuget.newtonsoft-json
安装,有时候直接搜搜不到
在Native AOT的场景下,是不支持反射和动态代码生成的,newtonsoft.json
使用了大量反射和动态代码生成,所以要使用System.Text.Json
的源生成方式调用。 这个就用到再说不搞那么细了。
#todo最下面有全局的Converter可用
Protobuf
文档(中文也有)讲的很明白,我用C#+unity。主要使用流程:写proto的配置文件;使用protoc编译成对应语言的类文件;在工程中引入protobuf库。然后就可以正常序列化发送接收了。
C#和unity中引用protobuf库可以自己编译也可以使用别人编译好的dll文件,好像没有跨平台限制兼容unity版本就可以,Unity放Plugins里面。或者github项目中的protobuf/csharp/src/Google.Protobuf
文件夹放到Assets里面。protoc命令安装不多说。
XML
XmlDocument
,XmlTextReader
,Linq
常用的三种,一般无脑用XmlDocument和Linq。 基本IO
读取XML常用的API
void Start()
{
XmlDocument xml = new XmlDocument();
string xmlPath = Path.Combine(Application.streamingAssetsPath, "TestXml.xml");
string xmlStr = File.ReadAllText(xmlPath);
xml.LoadXml(xmlStr); // 加载字符串为xml
xml.LoadXml(xmlPath); // 按路径直接加载文件
XmlNode root = xml.SelectSingleNode("Root"); // 获取根节点(xml有且仅有一个根节点)
XmlNode nodeName = root.SelectSingleNode("name"); // 通过根节点去获取下面的子节点
print(nodeName.InnerText); // 获取节点文本,默认已经做了CDATA的处理了
XmlNode nodeItem = root.SelectSingleNode("Item");
XmlNodeList ChildNodes = nodeItem.ChildNodes; // 遍历字节点,字节点支持Name和InnerText属性
print(nodeItem.Attributes["id"].Value); // 获取属性值
print(nodeItem.Attributes.GetNamedItem("num").Value); // 也可以这样获取
XmlNodeList friendList = root.SelectNodes("Friend"); // 同名节点 获取列表
print(xml);
}
写入修改XML常用API
void Start()
{
string xmlPath = Path.Combine(Application.streamingAssetsPath, "TestXmlWrite.xml");
XmlDocument xmlDocument = new XmlDocument();
XmlDeclaration xmlDec = xmlDocument.CreateXmlDeclaration("1.0", "UTF-8", ""); // 创建固定版本信息
xmlDocument.AppendChild(xmlDec);// 需要手动添加进去
XmlElement root = xmlDocument.CreateElement("Root"); // 创建根节点
xmlDocument.AppendChild(root);
XmlElement name = xmlDocument.CreateElement("name"); // 创建子节点
name.InnerText = "小明";
root.AppendChild(name); // 需要在对应的节点下Append
XmlElement listInt = xmlDocument.CreateElement("listInt");
for (int i = 1; i <= 3; i++) // 添加多个子节点
{
XmlElement childNode = xmlDocument.CreateElement("int");
childNode.InnerText = i.ToString(); // 内容为下标
childNode.SetAttribute("id", i.ToString()); // 设置id属性
childNode.SetAttribute("num", (i * 10).ToString()); // 设置num属性
listInt.AppendChild(childNode);
}
root.AppendChild(listInt);
xmlDocument.Save(xmlPath);
Debug.Log("------------------------------"); // 写入之后,读取并修改
XmlDocument newXml = new XmlDocument();
newXml.Load(xmlPath); // 读取刚才创建的那个
XmlNode node;
node = newXml.SelectSingleNode("Root").SelectSingleNode("name");
node = newXml.SelectSingleNode("Root/name"); // 直接用路径也可以
XmlNode root2 = newXml.SelectSingleNode("Root");//得到父节点
root2.RemoveChild(node); // 移除子节点方法
XmlElement moveSpeed = newXml.CreateElement("moveSpeed");
moveSpeed.InnerText = "20";
root2.AppendChild(moveSpeed); // 给你想加的加上
newXml.Save(xmlPath);
}
运行结果
<?xml version="1.0" encoding="UTF-8"?>
<Root>
<listInt>
<int id="1" num="10">1</int>
<int id="2" num="20">2</int>
<int id="3" num="30">3</int>
</listInt>
<moveSpeed>20</moveSpeed>
</Root>
序列化 XmlSerializer
接口提供了钩子,可以让一些内容单独处理逻辑,可以在里面单独处理字典
如果字段为 null
这个XML节点会被跳过,空节点都不会生成。如果有需求在IXmlSerializable.WriteXml
中自己写。
#todo 其实没搞完上班看这几个自定义API方式的用法区别
XML序列化常用API
#define USE_TYPE_1
// #define USE_TYPE_2
// #define USE_TYPE_3
using System;
using System.Collections.Generic;
using System.IO;
using System.Xml;
using System.Xml.Schema;
using System.Xml.Serialization;
using UnityEngine;
// 子节点类
public class ChildNode
{
[XmlAttribute("Test1")] // 序列化为属性
public int test1 = 1;
[XmlAttribute()]
public float test2 = 1.1f;
[XmlAttribute()]
public bool test3 = true;
[XmlIgnore] // 忽略字段,不序列化
public string temp = "不序列化的字段";
}
[XmlRoot("RootNode")] // 指定根节点名字
public class TestXML : IXmlSerializable
{
public int testPublic = 1;
[XmlElement("testPublicRenamed")]
public int testPublic2 = 2;
public string testPublicStr = "我是testPublicStr";
public int[] arrayInt = new int[3] { 1, 2, 3 };
[XmlArray("IntList")] // 列表节点的名字
[XmlArrayItem("Int32")] // 列表内元素节点的名字
public List<int> listInt = new List<int> { 1, 2, 3, 4 }; // 一般列表的序列化
public List<ChildNode> listItem = new List<ChildNode> { new ChildNode(), new ChildNode() }; // 类列表的序列化
private int testPrivate = 3; // 私有成员不会序列化
public Dictionary<int, string> testDic = new Dictionary<int, string>() { { 1, "123" }, { 2, "456" } }; // 直接序列化字典
public XmlSchema GetSchema() => null; // 通常返回 null
#if USE_TYPE_1
// ------------------------ 方式1 直接写serializer------------------------
public void WriteXml(XmlWriter writer) // XmlWriter:提供顺序写XML文档的API
{
new XmlSerializer(typeof(List<int>)).Serialize(writer, listInt); // Serialize:把对象写到writer
new XmlSerializer(typeof(List<ChildNode>)).Serialize(writer, listItem);
writer.WriteStartElement("DictList"); // WriteStartElement:写开始标签 <DictList>
foreach (var kv in testDic)
{
writer.WriteStartElement("Item"); // <Item>
writer.WriteAttributeString("Key", kv.Key.ToString()); // 写属性 Key="1"
writer.WriteAttributeString("Value", kv.Value); // 写属性 Value="123"
writer.WriteEndElement(); // </Item>
}
writer.WriteEndElement(); // </DictList>
}
public void ReadXml(XmlReader reader) // XmlReader:提供只进不退的方式读取XML节点
{
reader.MoveToContent(); // 定位到当前元素的内容(跳过声明/空白)
listInt = (List<int>)new XmlSerializer(typeof(List<int>)).Deserialize(reader); // Deserialize:从XML生成对象
listItem = (List<ChildNode>)new XmlSerializer(typeof(List<ChildNode>)).Deserialize(reader);
testDic.Clear();
if (reader.ReadToDescendant("Item")) // ReadToDescendant:跳到第一个<Item>
{
do
{
int key = int.Parse(reader.GetAttribute("Key")); // GetAttribute:读取属性值
string value = reader.GetAttribute("Value");
testDic[key] = value;
} while (reader.ReadToNextSibling("Item")); // ReadToNextSibling:跳到下一个<Item>
reader.ReadEndElement(); // 离开</DictList>
}
}
#elif USE_TYPE_2
// ------------------------ 方式2 手动写属性/节点 ------------------------
public void WriteXml(XmlWriter xmlWriter)
{
xmlWriter.WriteStartElement("testPublic"); // <testPublic>
xmlWriter.WriteElementString("Value", testPublic.ToString()); // <Value>1</Value>
xmlWriter.WriteEndElement(); // </testPublic>
xmlWriter.WriteStartElement("testPublicStr"); // <testPublicStr>
xmlWriter.WriteElementString("Value", testPublicStr);
xmlWriter.WriteEndElement();
}
public void ReadXml(XmlReader xmlReader)
{
xmlReader.MoveToContent();
if (xmlReader.MoveToAttribute("testPublic")) // MoveToAttribute:尝试移动到指定属性
testPublic = int.Parse(xmlReader.Value); // Value:当前节点/属性的值
if (xmlReader.MoveToAttribute("testPublicStr"))
testPublicStr = xmlReader.Value;
}
#elif USE_TYPE_3
// ------------------------ 方式3 循环读取NodeType ------------------------
public void WriteXml(XmlWriter xmlWriter)
{
xmlWriter.WriteStartElement("testPublic");
xmlWriter.WriteString(testPublic.ToString()); // WriteString:写文本内容
xmlWriter.WriteEndElement();
xmlWriter.WriteStartElement("testPublicStr");
xmlWriter.WriteString(testPublicStr);
xmlWriter.WriteEndElement();
}
public void ReadXml(XmlReader xmlReader)
{
xmlReader.MoveToContent();
while (xmlReader.Read()) // Read:前进到下一个节点
{
if (xmlReader.NodeType == XmlNodeType.Element) // NodeType:当前节点类型(Element/Text/EndElement)
{
switch (xmlReader.Name) // Name:节点名称
{
case "testPublic":
xmlReader.Read(); // 移动到<testPublic>的内容
testPublic = int.Parse(xmlReader.Value); // Value:当前文本节点的值
break;
case "testPublicStr":
xmlReader.Read();
testPublicStr = xmlReader.Value;
break;
}
}
}
}
}
#else
#endif
public class XMLExample : MonoBehaviour
{
void Start()
{
string path = Application.persistentDataPath + "/TestXMLSerialize.xml";
TestXML obj = new TestXML(); // 构造一个默认数据的类
print("XML存储路径:" + path);
// ---------------- 序列化 ----------------
using (StreamWriter writer = new StreamWriter(path)) // StreamWriter有他就打开没有他会新建。
{
XmlSerializer serializer = new XmlSerializer(typeof(TestXML));
serializer.Serialize(writer, obj); // 落盘
}
// ---------------- 反序列化 ----------------
using (StreamReader reader = new StreamReader(path)) // 读不到会System.IO.FileNotFoundException
{
XmlSerializer serializer = new XmlSerializer(typeof(TestXML));
TestXML deserialized = (TestXML)serializer.Deserialize(reader);
print("反序列化 testPublic: " + deserialized.testPublic);
print("反序列化 testPublic2: " + deserialized.testPublic2);
print("反序列化 testPublicStr: " + deserialized.testPublicStr);
print("反序列化 listInt[0]: " + deserialized.listInt[0]);
print("反序列化 listItem[0].test1: " + deserialized.listItem[0].test1);
}
}
}
Binary和基本IO
C#内置二进制序列化不多讲直接看API
基本IO常用API
void Start()
{
string path = Application.streamingAssetsPath;
print(File.Exists(path + "/t0.txt"));
FileStream fsc = File.Create(path + "/t1.txt");fsc.Close(); // 注意这个会覆盖已有文件
File.Delete(path + "/t1.dest.txt"); // 不存在不管存在就删
File.Copy(path + "/t1.txt", path + "/t1.dest.txt", true); // 最后参数控制有就覆盖,false则有就报错
File.Replace(path + "/t1.txt", path + "/t1.dest.txt", path + "/t1.back.txt"); // 前俩参数必须存在 第三个参数只备份内容
FileStream fso = File.Open(path + "/t1.txt", FileMode.OpenOrCreate, FileAccess.ReadWrite); // FileMode控制打开时的行为
// 其实上面两个打开的操作占了俩文件描述符
byte[] bytes = BitConverter.GetBytes(999); // 转字节
byte[] sbytes = Encoding.UTF8.GetBytes("测试写字符串数组");
byte[] Lenbytes = BitConverter.GetBytes(sbytes.Length); // 获取字符串数组多长,固定返回一个int4字节
File.WriteAllBytes(path + "/t2.t", bytes);// 这三个不存在就抛错DirectoryNotFoundException
File.WriteAllText(path + "/t3.txt", "我测");
File.WriteAllLines(path + "/t4.txt",new string[]{"a","b"}); // 一行一个写入字符串数组
// 打开文件流的三种方式(茴的四种写法?
FileStream fs1 = new FileStream(path + "/fs1", FileMode.Create, FileAccess.ReadWrite);
FileStream fs2 = File.Create(path + "/fs2",2048); // 相当于一个简化工厂方法,默认 FileMode.Create、FileAccess.Write 只写不读
FileStream fs3 = File.Open(path + "/fs3", FileMode.OpenOrCreate);// 也是相当于工厂方法,需要传 FileMode,可选 FileAccess 和 FileShareif(fs3.CanSeek)print("可寻址");
print(fs3.Length); // 文件流里有多少字节 不可寻址的文件句柄会报System.NotSupportedException: Stream does not support seeking
fs3.Write(Lenbytes,0,Lenbytes.Length); // 字节数组文件指针开始和结束
fs3.Write(sbytes,0,sbytes.Length);
if(fs3.CanSeek)print("可移动文件指针");
if(fs3.CanRead)print("可读");
if(fs3.CanWrite)print("可写");
fs3.Flush(); // 落盘
fs3.Close(); // 关闭描述符
fs3.Dispose(); // 自动fs3.Close()并回收缓冲区和其他资源
// 测试读取
FileStream fs4 = new FileStream(path + "/fs3", FileMode.Open, FileAccess.Read);
Lenbytes = new byte[4]; // 重置
int bytesReadLength = fs4.Read(Lenbytes,0,4); // Read会返回这次读取了多长(字节数)
int _length = BitConverter.ToInt32(Lenbytes);
sbytes =new byte[_length]; // 重置
int offset = 0; // 文件指针
byte[] buffer = new byte[2]; // 每次读取 2 字节
int bytesRead;
print("分块读取:");
while (offset < _length &&
(bytesRead = fs4.Read(buffer, 0, buffer.Length)) > 0) // 注意按字节读取是怎么读的
{
Array.Copy(buffer, 0, sbytes, offset, bytesRead);// 拷贝读取的数据到目标数组
offset += bytesRead; // 移动文件指针
}
string result = Encoding.UTF8.GetString(sbytes, 0, _length); // 完整读取后,转换为字符串
print("成功读取:"+result);
fs4.Dispose();
}
- 打开文件会占用系统文件描述符,如果打开的FileStream没有手动关闭,则会在GC的时候尝试调用
Finalize
,但是多久才会GC掉是未知的,最好记得关。 - 文件描述符被占用的时候重复打开
IOException: Sharing violation on path /Assets/StreamingAssets/t1.txt
- 如果操作的不是普通文件句柄(比如 socket、管道、标准输入输出等)那就不是CanSeek,fs.Length会报
System.NotSupportedException: Stream does not support seeking
- 写入的内容传入必须符合
ReadOnlySpan<byte> buffer
是一种 结构化的只读内存视图 - 写入字符串等不知道多长的,建议开头先写一个长度,读的时候读出知道多长再读内容
- 写入长度的时候,
fs.Write(lenBytes, 0, lenBytes.Length);
长度是可以接收Long的,但是超过int范围大小的sbytes.Length
会获取报错,使用sbytes.LongLength
获取Long类型的大小,读的时候也用Long的长度读 - 超过int的时候大约是2GB,这时候该考虑分块写入了
- 写入长度的时候,
- FileStream是Strean它在内部只维护一个 文件句柄和缓冲区(通常 4KB ~ 8KB)
Read
或Write
时从磁盘读取/写入对应部分的数据 File.ReadAllBytes()
或File.ReadAllText()
会一次性把整个文件读到内存大文件会 OOM- 如果想对大文件随机访问用
MemoryMappedFile
它只把访问的部分映射到内存
- 如果想对大文件随机访问用
- UTF-8 是 可变长度编码,每个字符占 1~4 个字节,按照字节读取不一定读到字符边界,需要读完之后整体转码。如果很大建议按块读然后按块转
FileMode行为 | 文件存在 | 文件不存在 |
---|---|---|
Create | 覆盖 | 创建 |
CreateNew | IOException | 创建 |
Open | 打开 | FileNotFoundException |
OpenOrCreate | 打开 | 创建 |
Truncate | 打开并清空 | FileNotFoundException |
Append | 只写打开指针移到末尾 | 创建 |
最好记一下每种变量占几个字节;Integral numeric types; Floating-point numeric types
文件夹和Path常用API
void Start()
{
string path = Application.streamingAssetsPath;
Directory.Exists(path+"/dir1");
DirectoryInfo dirInfo = Directory.CreateDirectory(path + "/dir1"); // 可以按照路径递归创建,已存在会直接返回文件夹信息
Directory.CreateDirectory(path + "/dir2");
string[] dirs = Directory.GetDirectories(path); // 返回绝对路径
Directory.Delete(path + "/dir1",false); // 删非空报错,加第二个参数相当于rm -rf 如果某些文件被锁定或无权限,仍然会抛异常
string[] files = Directory.GetFiles(path);
Directory.Move(path + "/dir2",path + "/dir3");// dest已经存在报错
print(dirInfo.Name);// Name文件夹名字;FullName带名全路径;Parent父文件夹名字(根目录返回 null);Extension点分割的后缀;Root始终返回根目录;
DirectoryInfo parentDir = Directory.GetParent(path + "/dir3"); // 获得目标的父文件夹
DirectoryInfo[] dirsDetail = parentDir.GetDirectories(); // 更详细一些,支持搜索默认*
FileInfo[] filesDetail = parentDir.GetFiles();
print(filesDetail[0].Length); // FileInfo可以获得Length
string filePath = path+"/demo.txt";File.Create(filePath);
string dirPath = path+"/dir3"; // 先创建示例
string combined = Path.Combine(dirPath, "subfolder", "file.txt"); //
print(combined); // 合并后 /Users/jack/code/dotnet/demobase/CoreProject/Assets/StreamingAssets/dir3/subfolder/file.txt 注意后面是怎么合并的
print(Path.GetFileName(filePath)); // demo.txt
print(Path.GetFileNameWithoutExtension(filePath)); // demo
print(Path.GetDirectoryName(filePath)); // /Users/jack/code/dotnet/demobase/CoreProject/Assets/StreamingAssets
print(Path.GetExtension(filePath)); // .txt
print(Environment.CurrentDirectory); // 当前工作目录 unity默认是项目根目录,可以通过更改这个值改掉
print(Path.GetFullPath(@"../demo.txt")); // 返回此路径的绝对路径,依赖当前工作目录/Users/jack/code/dotnet/demobase/demo.txt
print(Path.GetTempPath()); // 系统临时目录
print(Path.GetTempFileName()); // 临时文件(会创建一个空文件)
string newFile = Path.ChangeExtension(filePath, ".log");
print(newFile);
print(Path.IsPathRooted(filePath)); // 判断是否是绝对路径
print(Path.GetRandomFileName()); // 随机文件名,不会真创建(x4z1ab2f.tmp)
}
- 路径Win不区分大小写Linux/macOS区分大小写
- 支持相对路径,操作基于当前工作目录
Path.Combine
会从第一个绝对路径开始覆盖前面的路径
Path属性 | 说明 |
---|---|
Path.DirectorySeparatorChar | 当前系统目录分隔符 \ 或 / |
Path.AltDirectorySeparatorChar | 替代分隔符 / (在 Windows 可用) |
Path.PathSeparator | 路径列表分隔符 ; |
Path.InvalidPathChars | 不允许出现在路径中的字符数组 |
Path.MaxPath | Windows 最大路径长度(260 字节,.NET Core 可以超长) |
二进制常用API
public class BinarySerialize : MonoBehaviour
{
[System.Serializable]
public class Person
{
public string Name = "jack";
public int i = 1;
}
void Start()
{
Person person = new Person();
using (MemoryStream memoryStream = new MemoryStream())
{
BinaryFormatter binaryFormatter = new BinaryFormatter();
binaryFormatter.Serialize(memoryStream, person);
byte[] _bytes = memoryStream.GetBuffer(); // 对象的二进制数组拿到可以做很多事
File.WriteAllBytes(Application.streamingAssetsPath + "/person", _bytes);
memoryStream.Close();
}
using (FileStream fileStream = new FileStream(Application.streamingAssetsPath + "/person.cp", FileMode.OpenOrCreate, FileAccess.Write))
{
BinaryFormatter binaryFormatter = new BinaryFormatter();
binaryFormatter.Serialize(fileStream, person); // 序列化完直接写到一个打开的流中
fileStream.Flush();
fileStream.Close();
}
byte[] bytes = File.ReadAllBytes(Application.streamingAssetsPath + "/person");
using (MemoryStream memoryStream = new MemoryStream(bytes))
{
BinaryFormatter binaryFormatter = new BinaryFormatter();
Person personRestore = binaryFormatter.Deserialize(memoryStream) as Person; // 反正传个打开的Stream就行
memoryStream.Close();
print("还原Person:" + personRestore.Name);
}
}
}
使用的是内置的BinaryFormatter,类上加[System.Serializable]
特性进行序列化
- 支持内置的常用类型, Unity 支持
Vector3
,Quaternion
,Color
等内置[Serializable]
类型 - 字段必须是
public
或[SerializeField]
才能被序列化,不支持字典、接口、抽象类、委托、事件、循环引用
- 字段必须是
- 属性不会被序列化(只有字段)静态字段不会被序列化
[NonSerialized]
可以显式排除字段- 不支持循环引用
- 反序列化的时候类一定要有而且一定要一致!
- 其实他我没有全试,到时候用到再说,能用MemoryPack替代的时候先替代
MemoryPack
C#中的高性能二进制序列化,不跨语言,dotnet10(unity2021.3+)支持,需要额外安装,定位是替代C#内置二进制序列化,提供了生命周期hook函数。
- 默认不支持多态,需要手动实现
- 字段顺序按类代码顺序决定
- 默认序列化公共实例属性或字段,使用
[MemoryPackIgnore]
来移除序列化目标,[MemoryPackInclude]
将私有成员提升为序列化目标,所有成员都需要是可以被序列化的不然报错。
- 默认序列化公共实例属性或字段,使用
- 值类型需要标记可空,引用类型自动支持 null
- 如下实现零分配(重要优势,在 序列化 / 反序列化过程中不额外产生新的对象或临时数组,避免动态数组更容易触发GC,对CPU缓存更友好。我这里好像没法测试0分配 #todo 说实话搞不太明白了,回头有机缘了再弄,这东西应该能写到文件流中,试一下用网络流和本地文件流 安装要两步
MemoryPack序列化API
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using MemoryPack;
using System;
public class MemoryP : MonoBehaviour
{
void Start()
{
// 创建测试对象,包含多态引用
User user = new User { Id = 1001, Name = "Jack", Email = "jack@example.com",
Pet = new Dog { Name = "Buddy", HasTail = true } }; // 使用Union特性实现多态
// 预分配缓冲区,实现零分配(我的Mono好像不支持)
byte[] buffer = new byte[1024];
int bytesWritten = MemoryPackSerializer.Serialize<User>(user, buffer);
Debug.Log($"序列化字节长度: {bytesWritten}");
// 反序列化用户对象
User deserializedUser = MemoryPackSerializer.Deserialize<User>(buffer, 0, bytesWritten);
Debug.Log($"反序列化结果: Id={deserializedUser.Id}, Name={deserializedUser.Name}");
// 直接序列化到新分配的字节数组(简便但有内存分配)
byte[] data = MemoryPackSerializer.Serialize<User>(user);
User deserializedUser2 = MemoryPackSerializer.Deserialize<User>(data);
}
}
[MemoryPackable]
public partial class User
{
public int? Id { get; set; } // 支持可空类型
public string Name { get; set; } // 支持字符串
public string Email { get; set; }
public Animal Pet { get; set; } // 多态属性,使用Union特性实现
}
[MemoryPackable] // 使用MemoryPackUnion在抽象基类或接口上保证编译时 Source Generator 生效
[MemoryPackUnion(0, typeof(Dog))] // 使用索引0标识Dog类型
[MemoryPackUnion(1, typeof(Cat))] // 使用索引1标识Cat类型
public abstract partial class Animal
{
public string Name { get; set; }
}
[MemoryPackable]
public partial class Dog : Animal
{
public bool HasTail { get; set; } // 特有属性
}
[MemoryPackable]
public partial class Cat : Animal
{
public int Lives { get; set; } // 特有属性
}
ScriptableObject
Unity内置的可配置的对象可以理解为一个数据模板类,优势是可以直接在Inscpetor中编辑,在Editor下是持久化的,runtime停止之后也是会丢失的。
性能其实没有xml二进制json高
类似MonoBehaviour,需要继承ScriptableObject
类,inspector中的可见性和MonoBehaviour一样,直接关联ScriptableObject相当于设置默认值,会记录在对应的.meta文件中
可以注册[CreateAssetMenu()]
特性(ScriptableObject特有的)在右键在菜单创建,也可以在方法中调用创建,一般是在菜单栏静态方法里用
创建ScriptableObject
右键菜单创建
// 参数为创建出来的数据文件默认名字,右键菜单显示名字,排序优先级(升序)
[CreateAssetMenu(fileName ="jackData", menuName ="ScriptableObject/我的数据", order = 0)]
public class MyData:ScriptableObject
{
public int i;
public float f;
public bool b;
public GameObject gobj;
public Material m;
public AudioClip auCliper;
public VideoClip vidCliper;
}
注册到工具栏菜单的示例调用,ScriptableObject保存成.asset
文件:只能在Editor下(非运行时),最常用的就是AssetDatabase
API,这些通常会配合[MenuItem]
写在静态方法里。
public class ScriptableObjectTool
{
[MenuItem("ScriptableObject/CreateMyData")]
public static void CreateMyData() // MenuItem必须加到静态方法上
{
//书写创建数据资源文件的代码 <T>中一定要继承ScriptableObject
MyData myDataAsset = ScriptableObject.CreateInstance<MyData>();
AssetDatabase.CreateAsset(myDataAsset, "Assets/Resources/MyDataTest.asset");//通过编辑器API 根据数据scrpitableobj创建一个数据资源文件
AssetDatabase.SaveAssets();//保存创建的资源
AssetDatabase.Refresh();//刷新界面
}
}
在MonoBehaviour中声明对应的ScriptableObject变量,Inspector中用拖数据资源文件关联上去。
这东西也支持使用Rsources和AB包加载。文件被多个对象加载是共享状态的大部分对象都是线程不安全的,而且在线程中不能访问Monobehaviour和ScriptableObjcet对象
使用ScriptableObject
public class SoTest : MonoBehaviour
{
public MyData myData; // 可以在Inspector关联
void Start()
{ myData = Resources.Load<MyData>("jackData"); // 使用Resource加载
myData.PrintInfo();
}
}
场景比如怪物数值模板,可以在里面写一些类型什么的,然后在创建的时候在怪物的Awake中把ScriptableObjcet对象上的数值复制到普通类中
ScriptableObject不常用的生命周期
和MonoBehaviour类似,它也有生命周期函数数量较少一般不常用。
- Awake:在数据文件创建时调用。
- OnDestroy:在 ScriptableObject 对象将被销毁时调用。
- OnDisable:在 ScriptableObject 对象销毁时、即将重新加载脚本程序集时调用。
- OnEnable:在 ScriptableObject 创建或加载对象时调用。
- OnValidate:这是一个仅在编辑器下调用的函数,在 Unity 加载脚本或者在 Inspector 窗口中更改值时会被调用。
Warning
这东西在Editor中的行为是持久化的,但是打包出去就不会持久化了。
这东西其实不是设计来持久化游戏数据的,非要做可以结合其他的IO文件格式做
- 可以做配置文件更方便(非纯文本真的更好吗
- 用作公共数据解耦省内存,多个GameObject挂同一个数据资源文件,不会在内存中复制多份(只用不更改的数据用共用这一个节省一些,改变会影响到所有使用数据文件的对象
- 方便处理数据带来的多态
- 使用继承ScriptObject的基类,提供抽象方法,用里氏替换在脚本中挂载子类的数据文件,实现多态。
非持久化数据:
上面是创建了一个资源文件去关联,本地已经有一个持久化的文件了,所以更改会被应用。在runtime使用ScriptableObject.CreateInstance<MyData>()
创建的文件是在运行中动态创建的,特点:
- 运行完随着进程销毁,可以被GC,相当于只是继承了
UnityEngine.Object
- 指定的泛型不会带默认值过来,ScriptableObject的构造函数是Unity内部处理的,不是普通 C# 构造函数
- 解决办法是在ScriptableObject的
OnEnable()
中初始化
- 解决办法是在ScriptableObject的
ScriptableObject实现多态简单示例
// 声明一个抽象类AudioPlayBase,继承自ScriptableObject类
public abstract class AudioPlayBase : ScriptableObject
{
public abstract void Play(AudioSource source);
}
[CreateAssetMenu()]
public class RandomPlayAudio : AudioPlayBase
{
public List<AudioClip> clips;
public override void Play(AudioSource source)
{
if (clips.Count == 0) return;
int randomIndex = Random.Range(0, clips.Count); // 随机播放其中一个
AudioClip randomClip = clips[randomIndex];
source.clip = randomClip;
source.Play();
}
}
[CreateAssetMenu()]
public class PlayerAudio : AudioPlayBase
{
public AudioClip clip;
public override void Play(AudioSource source) // 播放单个
{
source.clip = clip;
source.Play();
}
}
public class SoTest : MonoBehaviour
{
public AudioPlayBase audioPlay; // 这个就可以挂载不同类型的PlayAudio实现多态
private void Start()
{
audioPlay.Play(this.GetComponent<AudioSource>());
}
}
#todo ScriptableObject保存和加载 最下面有全局的Converter可用
操作Excel
其实就是一种文件格式,没什么新奇的,我用pandas不是天天干这个吗。.xlsx
本质上是一个压缩包所以需要Zip功能去解压,一般是在Editor模式下去读写Excel进行操作,所以依赖dll很多时候也会放到Editor下。
使用这两个库SharpZipLib;ExcelDataReader(较新版本的需要把ExcelDataReader.DataSet.dll
ExcelDataReader.DataSet一块安装)
❌Failure
啊我草ExcleDataReader怎么这么坏啊😭
这俩库自己放DLLExcelDataReader.DataSet.dll
死活读不出来,但是使用NugetForUnity就没问题,里面不知道做了什么不为人知的邪恶py交易,除非单文件DLLL以后尽量能少自己凑。
(unity2022.3 dll3.7.0)
Excel常用API
void Start()
{
string excelPath = Application.streamingAssetsPath + "/PlayerInfo.xlsx";
FileStream fileStream = File.Open(excelPath, FileMode.Open, FileAccess.Read);
// xlsx 用 CreateOpenXmlReader,xls 用 CreateBinaryReader
IExcelDataReader reader = ExcelReaderFactory.CreateOpenXmlReader(fileStream);
DataSet dataSet = reader.AsDataSet(); // 转成 DataSet,里面包含所有表
for (int i = 0; i < dataSet.Tables.Count; i++)
{
Debug.Log("表名:" + dataSet.Tables[i].TableName); // 表名
Debug.Log("行数:" + dataSet.Tables[i].Rows.Count); // 行数
Debug.Log("列数:" + dataSet.Tables[i].Columns.Count); // 列数
foreach (DataRow row in dataSet.Tables[i].Rows) // 遍历行
{
foreach (var cell in row.ItemArray) // 遍历列
print(cell.ToString());
}
}
fileStream.Dispose();
}
牢唐的工具类思路就是,按照既定格式读取Excel,字符串拼接生成类型CS文件;按照顺序写入二进制到bin文件;读取二进制获得类和字段名,利用反射复赋值存到单例的字典中去。
常见打包设置
关注Resolution and Presentation
- Fullscreen Mode
- FullscreenWindow使用显示器的分辨率进行全屏显示,如果游戏分辨率低于显示器分辨率,可能会出现黑边
- Exclusive Fullscreen 修改显示器分辨率以匹配项目,全屏
- Maximized Window 最大化窗口
- Windowed窗口模式
- Resolution
- Default Is Native Resolution 使用显示器分辨率,不勾使用设置的分辨率 Windowed模式才有用
- Default Screen Height 游戏画面默认高度,Windowed模式才有用
- Mac Retina Support启用MacOS高DPI支持
- Run In background程序失去焦点可以继续运行而不暂停
Last Edit: 2025-09-20 17:10:07