第 5 章 UI Toolkit
本章内容类似字典,可在使用到时再来有针对性地查阅,学习效果更好
历史:
- 前身是 UIElement 系统(于 2018 版本发布,仅用于在 Editor 环境下开发编辑器面板)
- 2019 版本起,UIElement 正式支持运行时 UI 并且更名为 UI Toolkit
- 2021.2 版本起,UI Toolkit 被官方内置在 Unity 中,和 UGUI 地位相当
特点:
- 作为下一代 UI 系统,设计目标就是替代 UGUI(效率低:打开慢、卡顿)
- 既能制作运行时的 UI,也能制作编辑界面中的 UI
5.2 UI Builder 编辑器
5.2.1 StyleSheets 视图
5.2.2 Hierarchy 视图
5.2.3 Library 视图
5.2.4 Viewport 视图
5.2.5 Inspector 视图
5.3 UI 组件
5.3.1 Text 组件
5.3.2 Visual Element 组件
5.3.4 基础容器
5.3.5 ScrollView 组件
5.3.6 ListView 组件
5.3.7 ListView 事件
5.3.8 TreeView 组件
5.3.9 GroupBox 组件
5.3.10 控制组件
5.3.11 加载与嵌套
5.3.12 自适应 UI
5.3.13 动态图集
5.3.14 自定义 UI 组件
5.4 事件系统
5.5 进阶技巧
5.5.1 3D 与 2D 坐标转换
- 场景:3D 角色头上的血条
5.5.2 覆盖样式
5.5.3 多样式管理
5.5.4 调试
5.6 UI Toolkit 渲染
第 6 章 2D 游戏开发
- 不能直接用 UGUI 的 UI 元素来开发 2D 游戏
- UGUI 的特点是利用 Canvas 动态合并 Mesh,提升渲染效率
- 若角色是 UI 元素,那么角色的任何视角更新都会触发 Canvas 去合并 Mesh,影响性能
- Unity 提供了 Sprite Renderer 组件,配合 Animator 来控制播放 2D 精灵动画
第 7 章 3D 游戏开发
7.1 Renderer
- 它负责将 Mesh(网格)渲染出来
7.1.1 Mesh Renderer(网格渲染器)
- Mesh Renderer 是静态模型的渲染器,需要配合 Mesh Filter 组件(拖入 FBX 模型的原始网格信息)使用
- Mesh Renderer 添加材质球,材质球中绑定了
Shader,因此就可把模型渲染出来
7.1.2 Skinned Mesh Renderer(蒙皮网格渲染器)
-
需配合蒙皮动画(美术人员制作)使用,动画文件名命名规则通常是 {模型名}@{动作名}。如 Mage@Idle
-
模型想要播放动画,必须保证动画文件中的
骨骼节点与模型的一致,所以- 模型文件,需要包含
骨骼节点和网格信息 - 动画文件,需要包含
骨骼节点和动画信息
- 模型文件,需要包含
-
将模型拖入 Hierarchy 视图会给网格部分自动添加 Skinned Mesh Renderer 组件
-
模型文件属性面板中,Animation Type 支持人形(Humanoid)和非人形(Generic)
- 人形骨骼支持重定向功能(同一套骨骼可在多个模型中使用)
- 美术人员制作的
模型动画有时并非标准人形,如有尾巴的怪物 - 标准人形节点以外的动画也都需要绑定骨骼,但无法和其他模型一起复用,故人形骨骼节点就不适合了
- 实际项目中大多采用非人形骨骼节点,它可播放任意模型动画
7.1.3 Particle System(粒子系统)
- 原理:底层就一个脚本,运行时根据一系列参数动态展开,程序需动态创建 Mesh 并通过数学算法来计算它的运动轨迹
- 单个 Mesh 的顶点数较少,但 Mesh 总量较多,因此在内部用到了
动态合批技术- 所有粒子会被合并在一个 Draw Call 中
7.1.4 Trial Renderer(拖尾渲染器)
- 在物体运动前进方向的反方向添加一个拖尾特效
7.1.5 Line Renderer(线渲染器)
- 在 3D 世界中画线
7.1.6 Terrain(地形)
7.2 游戏对象和资源
7.2.1 静态对象
- 在整个游戏过程中都不会发生位置或效果改变的对象,可通过静态对象对它们进行一些特殊优化
- 除了可在对象属性面板右上角勾选 Static 来开启,还可以通过代码
// 设置静态总开关 go.isStatic = true; // 单独设置指定静态子开关 var flags = StaticEditorFlags.BatchingStatic | StaticEditorFlags.ReflectionProbeStatic; GameObjectUtility.SetStaticEditorFlags(go, flags);
7.2.2 标记
-
可为游戏对象设置一个唯一的标记(Tag)
- EditorOnly:表示它只在编辑器模式中存在,打包后将会被删除
- MainCamera:绑定在摄像机上,在代码中可使用 Camera.main 方便取得该摄像机
7.2.3 层
-
可为游戏对象设置一个唯一的层(Layer)
- TransparentFX 层:需配合 Flares(光晕)使用
- Ignore Raycast 层:忽略射线检测
- Water 层:在 Standard Assets 中使用
-
代码中设置层:
go.layer = LayerMask.NameToLayer("UI"); -
层通常和 LayerMask 配合使用,常用场景是让摄像机只显示指定的层
// 只看 UI 和 Water 层 Camera.main.cullingMask = 1<<LayerMask.NameToLayer("UI") | 1<<LayerMask.NameToLayer("Water"); // 删除 Water 层,只看 UI 层 Camera.main.cullingMask &= ~(1<<LayerMask.NameToLayer("Water"));
7.2.4 Prefab
- Unity 引擎默认不直接使用原始资源,而是会根据原始文件生成一份 .meta 文件来使用
- 不仅保存了额外的配置项,还保存了一个更重要的 GUID
- GUID 用于标记文件确保在工程中的唯一性
- prefab 就是通过 GUID 来引用原始资源
7.2.5 Prefab 嵌套
7.2.6 Prefab 和游戏对象
- Prefab
- 在 Project 视图中的是游戏资源,可保存在硬盘中
- 在 Hierarchy 视图中的是实例化后的游戏对象
- 游戏对象是内存中的数据,无法保存在硬盘中
7.2.7 实例化
- Instantiate 可对一个游戏资源或游戏对象进行实例化/克隆
- 还支持实例化组件——Instantiate<T>
7.2.8 游戏资源
-
Unity 使用的资源分两种
- 外部资源:通过第三方工具产生的资源。如贴图、模型、声音
- 内部资源:在 Unity 编辑中创建的资源。如 Prefab、材质、场景、脚本
-
Unity 不会直接使用外部资源,而是将它们生成另一份资源来使用
- 保存在 Library/Artifacts 目录中,文件名是生成的资源的 MD5 名称,所在目录名以对应 MD5 的前 2 位字符命名
7.2.9 场景
- 可叠加多场景(适用场景)
- 加载场景
- 同步:SceneManager.LoadScene
- 异步
private AsyncOperation mAsync; private void Start() { mAsync = SceneManager.LoadSceneAsync("SceneName", LoadSceneMode.Single); mAsync.complete += (async) => { Debug.Log($"加载完成: {mAsync.progress}"); }; } private void Update() { if (!mAsync.isDone) { } } - 建议单独封装一个加载类,避免同时存在当前与新场景,出现内存压力
- 加载一个空场景,当前场景会被卸载,然后再加载新场景
7.2.10 场景模板
-
方便在场景创建面板中,创建自定义的场景
-
方法:Project 视图 - Create - Scene - Scene Template
7.3 动画系统
- 引擎内置的动画编辑器没有提供骨骼动画的处理功能,只能编辑模型在每一帧的 Transform 信息
- 复杂动画需要用第三方软件来制作,然后导出为 FBX 文件,导入到 Unity 来使用
- Animator 用来管理动画之间的切换关系
- TimeLine 用来管理模型的进度关系,如过场动画、技能编辑器或者 3D 动画
7.3.1 动画文件
概述
-
Animation 是老版动画文件,只能播放动画,无法处理动画间的过渡与混合
-
Animator 是新版动画文件,可实现老版动画实现不了的功能
-
从效率角度看,Animation 不比 Animator 差,因大部分动画都比较简单,对动画的过渡/混合没有很高的要求
-
打开 .anim 文件,
m_Legacy用于标识是否是老版动画,将它改为 1 的话,就能切换至老版动画。下图是新旧动画的属性面板对比- 新版动画几乎能被老版动画完美兼容(啥意思?)
- 只有在 Sprite 帧动画切换时必须使用新版的,其他情况都可用老版
-
老版动画只能使用 Animation 组件播放
-
新版则要使用 Animation Controller 配置默认动画后,使用 Animator 组件播放
-
推荐方案
- 使用老版动画:处理普通特效动画
- 使用新版动画+Playable(避免了使用 Controller):处理角色动画行为
应用
- 动画系统并不提供动画结束回调事件,需自行实现(协程)
public Animation ani; void Start() { PlayAnimation(); } private void PlayAnimation(GameObject arg0) { StartCoroutine( Play("Attack", () => { Debug.Log("Play complete"); } ) ); } private IEnumerator Play(string aniName, Action onFinish) { ani.Play(aniName); // 等待动画时长后执行回调 yield return new WaitForSeconds(ani[aniName].length); onFinish?.Invoke(); }
7.3.2 制作动画
7.3.3 动画事件
步骤:
-
在挂载动画组件的对象上挂载一个脚本,用于定义事件回调方法
public void OnKeyEvent(int k) { Debug.Log($"Key event, param: {k}"); } -
在动画时间轴上添加帧事件,并配置好事件接收方法名和参数
播放动画至该帧时,会输出
-
动画还可以修改挂载在游戏对象上脚本的公开属性
7.3.4 骨骼动画优化
7.3.5 播放动画
-
使用 Animator+Playable 来播放
public List<AnimationClip> aniClips = new(); private Animator mAnimator; private PlayableGraph mPlayableGraph; private Dictionary<string, AnimationClip> mDict = new(); void Start() { mAnimator = GetComponent<Animator>(); foreach (var clip in aniClips) { mDict[clip.name] = clip; } UGUIEventListener.Get(gameObject).onClick.AddListener(OnModelClick); } void OnDestroy() { mPlayableGraph.Destroy(); } private void OnModelClick(GameObject go) { StartCoroutine( Play( "Die", () => { Debug.Log("Play complete."); } ) ); } private IEnumerator Play(string aniName, Action onFinish) { var clip = mDict[aniName]; AnimationPlayableUtilities.PlayClip(mAnimator, clip, out mPlayableGraph); yield return new WaitForSeconds(clip.length); onFinish?.Invoke(); }
点击模型就会播放动画
7.3.6 切换动画
7.3.7 动画混合
- 通过一个权重值按比例影响骨骼节点,就能使多个动画混合同时播放(从待机混合过渡到跑步)
public Slider slider; public AnimationClip clip1; public AnimationClip clip2; private Animator mAnimator; private PlayableGraph mPlayableGraph; private AnimationMixerPlayable mAniMixerPlayable; void Start() { mPlayableGraph = PlayableGraph.Create(); var playableOutput = AnimationPlayableOutput.Create(mPlayableGraph, "Foo", mAnimator); // 准需要要混合的动画 var aniList = new List<AnimationClipPlayable>() { AnimationClipPlayable.Create(mPlayableGraph, clip1), AnimationClipPlayable.Create(mPlayableGraph, clip2), }; // 创建混合 playable mAniMixerPlayable = AnimationMixerPlayable.Create(mPlayableGraph, aniList.Count); playableOutput.SetSourcePlayable(mAniMixerPlayable); // 将所有混合的 Playable 关联到主 Playable 中 for (int i = 0; i < aniList.Count; i++) { mPlayableGraph.Connect(aniList[i], 0, mAniMixerPlayable, i); } mPlayableGraph.Play(); } void Update() { if (mAniMixerPlayableCreate) { // 通过权重来决定播放比例 float weight = Mathf.Clamp01(slider.value); mAniMixerPlayable.SetInputWeight(0, 1f - weight); mAniMixerPlayable.SetInputWeight(1, weight); } }
效果
7.3.8 老版动画
-
老版动画也支持 FBX 动画系统
-
老版动画没有 Optimize Game Objects 功能,在导入类 FBX 骨骼动画时一定要使用新版动画
-
老版 FBX 动画可直接绑定在 Animation 组件上播放
animation.Play("Idle"); // 队列动画 animation.PlayQueued("Idle"); animation.PlayQueued("Attack"); // 动画融合 animation.CrossFade("Run", 0.3f);
7.3.9 Simple Animation 组件
- 因 Playable 的接口比较底层,不易理解和操作,所以 Unity 开发了此动画系统,让我们可像操作老版动画一样来操作新版动画
7.3.10 控制动画进度
- 使用
simpleAni["Attack"].time来控制播放进度SimpleAnimation simpleAni = GetComponent<SimpleAnimation>(); // 从头开始播放 simpleAni.Play("Attack"); simpleAni["Attack"].time = 0;
7.3.11 特殊动画行为
- 动画过渡:
CrossFade - 动画混合:
Blend - 重新播放:
Rewind - 倒播
// 从尾以 -1 的速度播放 ani.time = ani.length; ani.speed = -1; - 播放区间,即从某个时间点开始播放:
ani.time = - 暂停与恢复:
ani.speed = 0; ani.speed = 1;
7.3.12 Playable 组件
- 它并不是针对动画系统开发的,其实是通过 PlayableGraph 树形结构处理数据的创作工具,支持混合修改多个数据并输出
- 它还支持音频、脚本,属于一个通用的 API
7.3.13 使用 Playable 自定义脚本
- 让我们可以在代码中更精准地控制每一帧的状态
适用场景(详情):
- 动态混合多个动画
- 角色的 “受伤动画” 需要根据受击方向(前 / 后 / 左 / 右)动态混合 4 个基础受伤动画片段,且混合权重随受击力度实时变化
- 实现自定义动画逻辑(如根运动、IK 修正)
- 角色在不平坦地形上行走时,根据地面高度调整角色根骨骼位置,避免角色 “漂浮” 或 “陷入地面”
- 多轨同步控制(动画 + 音频 + 特效的精准同步)
- 角色释放技能时,需要同步播放:技能动画(动画轨)、吟唱音效(音频轨)、手部粒子特效(特效轨),且当技能被打断时,所有轨道需同时停止并回退到初始状态
- 程序化生成动画(无需预制动画剪辑)
- 生成一个 “蛇形怪物” 的动画:通过 ScriptPlayable 在每帧计算蛇身每个关节的位置(基于正弦曲线),直接输出骨骼变换数据,驱动模型动画
- 优化复杂动画逻辑的性能
- 开放世界游戏中,远处的 NPC 只需播放简化的 idle 动画,近处的 NPC 需播放复杂的交互动画。通过 ScriptPlayable 可动态启用 / 禁用高复杂度的动画轨,降低远处 NPC 的性能开销
7.3.14 模型换装
通常有 3 种方法
- 更换材质:只更换贴图、颜色、着色器
- 更换模型:更换不带蒙皮信息的 Mesh,用于一些挂点的位置(头饰、武器等)
- 更换蒙皮模型:需保证 Skinned Mesh 可被相同的骨架控制
7.4 3D 物理系统
7.4.1 角色控制器(Character controller)
- 只需要简单的碰撞检测,并不需要完整的物理效果(影响性能)
- 和刚体一样,不能使用 Transform 来更改位置,必须使用 Move 和 SimpleMove 方法
7.4.2 碰撞体
-
相对于 2D 碰撞体,多了 Provides Contacts:是否抛出物理碰撞的事件
-
Include Layers 和 Exclude Layers 会覆盖项目设置,强制让指定的几个层之间发生/不发生碰撞
(覆盖项目的层碰撞配置)
7.4.3 Rigidbody 组件
7.4.4 碰撞事件
- 跟 2D 一样分碰撞和触发两类事件
7.4.5 布娃娃系统
- 通过多个碰撞体将刚体连接一起,再通过角色关节实现布娃娃效果
7.4.6 布料系统
- 适用于 Skinned Mesh Renderer 组件(没的话会自动添加)
7.4.7 其他
- Joints(关节)
- Articulations(模拟机械臂和运动链条)
7.5 输入系统
7.6 Transform 组件
第 8 章 静态对象
- 静态对象是 Unity 提供的一个属性,可附加在游戏对象或 Prefab 上
- 原理是限制物体在运行中发生位移变化
- 需要预生成一些辅助数据,用内存换时间来优化渲染效率
- 任意游戏对象的属性面板右上角的 Static 下拉框,即可设置该对象的静态元素
8.1 光照贴图
将场景中的光源与物体的光影信息烘焙在一张或多张光照贴图中,这些物体将不再参与实时光照计算,且不能移动
-
游戏中的物体通常分成两类:可位移的(使用实时光照),和不可位移的(使用预烘焙的光照贴图)
-
参与烘焙的模型需要启动第二套 UV,用于在光照烘焙贴图中对颜色采样(采样颜色乘以表面漫反射得出结果,而不参与实时光照计算)
8.1.1 光源
8.1.2 开始烘焙
-
模型必须包含 Mesh Renderer,设置如下
-
光照设置,Window - Renderering - Lighting 打开光照设置面板
-
设置完成后点击按钮开始烘焙
-
贴图在自动创建的场景目录下
8.1.3 烘焙结果在不同平台上的差异
- 为什么 PC 和手机上的烘焙效果不一样?
- 烘焙贴图纹理的编码不一样(PC: RGBM, mobile: dlDR)
- dlDR:超过 2 的光照强度将被强制限制在 2 以内,然后再映射到 0-1,这就导致光照强度较大的物体在两个平台上表现不一致
8.1.4 烘焙贴图 UV
-
烘焙完成后,再次选择模型对象,可查看贴图信息
8.1.5 光照探针
- 烘焙动态物体的阴影时可使用
Shadowmask,而烘焙它们表面的颜色就需要使用光照探针- 原因:光照信息已经预告烘焙在贴图中了,所以移动场景的烘焙光是无法影响主角的
- 通过光照探针可将不同区域的光预先保存下来,然后在运行时还原
8.1.6 运行时更换烘焙贴图
- 适用场景:昼夜更替
Texture2D lightmap1;
void ChangeLightmap() {
LightmapData data = new();
data.lightmapColor = lightmap1;
LightmapSettings.lightmaps = new LightmapData[1]{ data };
}
8.1.7 复制烘焙信息
- 烘焙贴图的采样结果是通过模型的第二套 UV 和偏移值确定的,这会引起一个问题,在烘焙后的场景中复制一个模型后,该模型的颜色会变黑(好像没重现出来)
- 针对需要动态创建的物体,可使用复制烘焙信息的方法来临时解决,但缺少阴影信息会让对象没有阴影
var meshRenderer = GetComponent<MeshRenderer>();
// 复制烘焙信息
meshRenderer.lightmapIndex = CopyRenderer.lightmapIndex;
meshRenderer.lightmapScaleOffset = CopyRenderer.lightmapScaleOffset;
8.1.8 复制工具
8.2 反射探针
- 用于实现物体对周围环境的反射(如金属和镜面)
- 原理:在探针周围烘焙出一张立方体贴图(Cubemap)来记录探针周围的环境信息。在绘制反光物体时,通过视线和法线求得表面的反射方向,然后在贴图中采样上色
- 若环境不变,就可预告生成反射图,否则就得实时生成。场景通常使用静态烘焙
- 对需要参与烘焙反射的物体勾选 Reflection Probe Static
- 场景中添加 Light - Reflection Probe
- 设置好参数后,点击 Bake 开始烘焙
- 很多游戏的动态反射功能,是在摄像机与地面的纵向轴镜像的位置上再创建一个摄像机,优先将参与反射的物体绘制一次,接着在正式摄像机渲染时将绘制的反射结果叠加上去
8.3 遮挡剔除
- 目的:减少提交给 GPU 的顶点信息,减轻 GPU 渲染压力
8.3.1 遮挡关系
(运行后预期效果)
8.3.2 遮挡与被遮挡事件
8.3.3 动态剔除
- 在 Mesh Renderer 组件中勾选 Dynamic Occlusion,以标记为动态剔除对象
8.3.4 遮挡剔除的原理
- 将场景分成很多个小格子,计算出几何物体所占小格子的区域及相邻格子间的可见性数据
- 只要物体在运行时不发生改变,那么它们的相对遮挡关系是固定的,可以根据这些数据和摄像机来决定是否要剔除物体
8.3.5 裁剪组(Culling Group)
- 若运行时把数据准备好,关闭静态烘焙也能实现遮挡剔除
public List<GameObject> gos;
private CullingGroup mCullingGroup;
void Start() {
mCullingGroup = new();
mCullingGroup.targetCamera = Camera.main;
// 根据对象坐标生成包围盒
BoundingSphere[] spheres = new BoundingSphere[gos.Count];
for (int i = 0; i < gos.Count; i++) {
spheres[i] = new(gos[i].transform.position, 1f);
}
mCullingGroup.SetBoundingSpheres(spheres);
mCullingGroup.SEtBoundingSphereCount(gos.Count);
mCullingGroup.onStateChanged = (evt) => {
// 监听裁剪状态,动态隐藏或显示游戏对象
if (evt.hasBecomeVisible) {
gos(evt.index).SetActive(true);
} else if (evt.hasBecomeInvisible) {
gos(evt.index).SetActive(false);
}
};
}
void OnDestroy() {
mCullingGroup?.Dispose();
mCullingGroup = null;
}
8.4 静态合批
- 可将多个模型合并在一个网格里,一次性提交给 GPU,减少 Draw Call
8.4.1 设置静态合批
-
Project Setting - Player - Other Settings,勾选 Static Batching
-
选中需要进行合批的对象,勾选 Batching Static。运行游戏后,若游戏对象的材质、Shader 一致,就能成功合批
(合批前)
(合批后)
-
坑:参与合批的网格顶点的所有数据(颜色、UV、法线、切线等),即使在合并后的网格中不需要,它们也会被添加到其中,大大增加了网格的内存占用
-
在编辑器模式下会以 Combined Mesh 的形式存在,构建时才会离线生成独立的网格
8.4.2 脚本静态合批
- 自动静态合批问题:
- 若场景非常大,合并后的网格将会非常大
- 运行游戏后,只要有一个对象需要显示,就会加载渲染整个网格数据
- 最大顶点数是 65535,若顶点数过多,就会自动增加合批网格,影响合批效果
- 可使用脚本动态设置需要参与合批的对象,而不需在编辑器中将它们设为 Static
public GameObject[] datas;
public GameObject root;
void Start() {
// 参数 1:参与合批的所有游戏对象;参数 2:作为根节点的游戏对象
StaticBatchingUtility.Combine(Datas, gameObject);
}
8.4.3 动态合批
- 条件苛刻:Mesh 顶点数小于 300,若 Shader 中使用了顶点位置、法线、UV0、UV1和切线,则顶点数必须小于 100
- 在粒子特效的场景中适用
- 现在更推荐使用 SRP 中的 SRP Batch 功能
8.5 导航网格
- Unity 寻路使用的是导航网格和 A* 寻路算法
- 寻路分
动态寻路和静态寻路(障碍物位置不变) - 导航网格算法比算格子的传统算法(如 A*)有更大的优势
- 保存的数据量小很多
8.5.1 设置寻路
- 现在不需要再对静态对象设置 Navigation Static 和 Off Mesh Link Generation
步骤:
- 给场景模型根节点绑定 NavMeshSurface 组件
- 点击 Bake 按钮烘焙场景的导航网格
- 给要寻路的角色对象绑定 Nav Mesh Agent 组件
- 设置好各个参数
- 运行时设置目标位置就能开始寻路移动
void Update() {
if (Input.GetMouseButtonDown(0)) {
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit)) {
// 开始寻路
mNavMeshAgent.destination = hit.point;
}
}
}
8.5.2 动态阻挡
- 给需要动态阻挡的对象添加 Nav Mesh Obstacle 组件来设置显示/隐藏
- Carve:是否支持动态烘焙
- Move Threshold:在移动多长距离后启动动态烘焙
- Time To Stationary:在元素停止运动多久后将其标为静态
- Carve Only Stationary:是否需要移动
- Carve:是否支持动态烘焙
8.5.3 连接两点
- 只能用非常规方式通过的地方(跳、飞),添加 Off Mesh Link 组件
8.5.4 连接两个区域
8.5.5 烘焙修改器
8.5.6 运行时烘焙
- 打开 FBX 模型文件的读写权限(代价:内存占用翻倍)
8.5.7 获取寻路路径
- 在寻路之前判断一下目标点或路径是否合法
8.5.8 导出导航网格信息
- 导入服务器使用