《Unity 3D 游戏开发》基础篇精华(1-4章)

157 阅读9分钟
  • Unity 使用 Mono 跨平台的特性并使用 C# 作为游戏脚本语言
  • 随着 .NET Core 跨平台的支持,Unity 从 2024 年开始将全面抛弃 Mono,拥抱 .NET Core
  • 为了更好地与各平台的 C++ 接口交互并支持 64 位系统,Unity 开发了 IL2CPP 工具,发布游戏时会先将 C#代码转换 C++ 代码,这样就可以统一进行编译打包

第 1 章 基础知识

1.3 协作开发

  • TimeLine 工具可让美术人员和策划人员可在不编写代码的情况下制作复杂的动画
  • 新版本中提供了 Visual Scripting(可视化脚本),让用户不写代码而只需通过拖曳节点就能轻松地制作游戏

1.4 Unity 版本

  • 开发实际项目时,最好使用 LTS 版本
  • Unity 有很多资源内置在引擎中,开发者是无法看到的。另还有一部分扩展资源,需开发者自行下载放入工程

第 2 章 编辑器的结构

第 3 章 游戏脚本

3.1 C# 运行时

  • 在开发脚本时,Unity 会通过编译器(Roslyn)将 C# 脚本编译成 IL 指令(中间指令集)

  • 最终的 IL 指令会被编译到 Library/ScriptAssemblies/Assembly-CSharp.dll 文件中

  • IL 指令和汇编语言比较像,但它们的原理其实是不同的:汇编语言对应 CPU 机器码,是 CPU 能识别且运行的;但 CPU 不认识 IL 指令

  • 运行时,IL 指令需经 Mono 运行时翻译成 CPU 认识的机器码,然后再交给 CPU 执行

  • Mono 对于 Unity 的意义是?后来为何要全面脱离 Mono

  • 在使用 IL2CPP 技术后,用户的 C# 代码被编译为 C++ 代码了,在最终构建时就可以很方便地在目标发布平台进行打包(安卓的 APK 和 iOS 的 Xcode 都只可以一键生成)

    image.png

3.2 C# 与引擎交互

  • Unity 是一个标准的 C++ 游戏引擎,在运行模式下提供了 UnityEngine.dll 库,在编辑器模式下提供了 UnityEditor.dll 库

  • 用户的 C# 代码会先调用上面两个库,然后再调用 Unity 底层的 C++ 核心代码

    image.png

  • 代码只要放在 Editor 目录或其子目录下,就会被编译到 Editor 对应的 DLL 中,否则就会被编译到 Runtime 对应的 DLL 中

3.3 游戏对象

3.4 游戏组件

3.4.1 Transform(变换)组件

  • 若需要同时修改位置和旋转,使用下面接口可提升效率
transform.SetPositionAndRotation(position, rotation);

3.4.2 Mesh Filter(网格过滤器)

  • 仅保存了 Mesh 信息,并不会真正参与渲染

3.4.3 Mesh Renderer(网格渲染)

  • 为何要与 Mesh Filter 分开?因为 Mesh Filter 可和多个不同的 Renderer 组件进行组合
  • 需要指定一种材质——它会绑定一个 Shader,最终决定了网格如何渲染

3.4.4 Skinned Mesh Renderer(蒙皮网格渲染)

3.5 游戏脚本

image.png

3.6 脚本的生命周期

image.png

3.6.2 二次初始化

  • Reset 方法在游戏运行时是不会执行的,它通常用来做一些编辑器下的脚本初始化工作

3.6.3 固定更新

  • 可保证每帧以一个固定的间隔执行 FixedUpdate() 方法,对帧率有稳定要求的地方需要使用它,比如动画系统和物理系统的更新 (底层原理

3.6.4 内置协程回高

3.6.5 鼠标事件

  • 鼠标事件只能在 PC 上使用
  • 使用方法:
    • 绑定一个碰撞器组件
    • 脚本中实现相关回调
    void OnMouseDown()
    void OnMouseUp()
    ...
    

3.6.6 脚本逻辑更新

3.6.7 场景渲染

3.6.8 编辑器 UI 更新

3.7 脚本的管理

3.7.1 脚本的执行顺序

  • Project Settings - Script Execution Order 中可调整(数值越小优先级越高,越先执行)
  • 设置当前脚本的执行顺序
    [DefaultExecutionOrder(100)]
    public class NewBehaviourScript : MonoBehaviour {}
    
  • 让指定静态方法成为首个执行的方法(在所有 Awake 之前执行)
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
    static void Main() {}
    

3.7.2 多脚本优化

  • 脚本绑定得越多,执行效率就越低。Unity 在执行生命周期方法时要:
    1. 遍历把当前所有脚本找出来
    2. 反射调用每个脚本的方法
  • 优化:
    • 避免绑定太多脚本
    • 避免在脚本中保留空的生命周期方法
  • 一个脚本更改 100 个对象的位置,比 100 个对象自身的脚本更改自身位置高效

3.8 脚本序列化

  • 脚本自身并没保存数据,而是将数据保存在文件中
    • 在场景中的对象所挂的脚本,其数据保存在场景文件(*.unity)中
    • 预制体所挂的脚本,其数据保存在预制体文件(*.prefab)中

3.8.1 查看数据

3.8.2 序列化数据标签

[System.NonSerialized]  // 不参与序列化
[HideInInspector]  // 参与序列化,但不显示在属性面板中
[SerializeField]  // 强制参与序列化,尽管属性为私有

3.8.3 SerializedObject

3.8.4 监听修改事件

3.8.5 序列化/反序列化监听

3.8.6 序列化嵌套

3.8.7 序列化引用

3.8.8 序列化继承

  • 序列化继承只会将当前类及其父类的值进行序列化
[Serializable]
public class Data
{
    public int value = 1;
}

[Serializable]
public class Child1 : Data
{
    public float floatValue;
}

public List<Child1> items;

3.8.9 ScriptableObject

  • 游戏中编辑配置相关的数据是海量的,强烈推荐使用 ScriptableObject 来设置和读取数据

3.9 脚本属性

3.10 协程任务

3.10.2 当前帧最后执行

  • 适合单帧内多次修改某个值,在帧结束时才生效
yield return new WaitForEndOfFrame();

3.10.3 定时器

// 协程
yield return new WaitForSeconds(second);

// C#(开启子线程)
async void InternalDelay(int time, CalcellationToken token, Action onFinish) {
    try {
        await Task.Delay(time, token);  // 开启线程,主线程并非卡住
        finish?.Invoke();  // 定时结束后返回主线程,抛出结束事件
    } catch (TaskCanceledException) {
    }
}

void Start() {
    var source = Delay(1000, () => {
        Debug.Log("1 秒后回调");
    });
    
    // source.Cancel():
}

CancellationTokenSource Delay(int time, Action onFinish) {
    CancellationTokenSource source = new();
    InternalDelay(time, source.Token, onFinish);
    return source;
}

3.10.4 CustomYieldInstruction

  • 总共 10 秒,每秒回调一次的计时器

3.10.5 Awaitable

3.11 通过脚本操作对象和组件

3.12 调试

第 4 章 UGUI

4.1 文本

4.1.3 描边和阴影

  • Text 会先根据文字对应的 Unicode 码在 TTF 字体库中提取字的型号,然后将它绘制在 Font 纹理位图中。根据包围盒的大小,用两个三角面就能确定一个字体渲染的 Mesh
  • 为实现描边效果,UGUI 采用的方式是额外生成 4 个面片(即 8 个三角面),即渲染一个文字就需要画 5 次
  • 阴影需要对每个文字的面片增加一倍的开销,千万不要同时使用描边和阴影

4.1.4 动态字体

  • 原理是根据传入的文字及字体大小将其生成到字体纹理上,文本网格通过 UV 信息在纹理上采样贴图,将文字显示出来
  • Text 组件被赋值新文本后,UGUI 会通过字符串试图去字体纹理中寻找,若没找到便会到 TTF 字体库中寻找并将其渲染到字体纹理中,再返回给 Text 组件进行渲染
  • 即使是完全相同的字,若字体大小不同,也会在纹理图中保存多份
  • Unity 推荐的解决方案是 Text Mesh Pro 矢量图的方式来渲染字体

4.1.5 字体花屏

  • 原因:虽然纹理位图已经扩容,但文本网格顶点数据中的 UV 并没更新,导致采样错误
  • 解决:
    • 监听 Font.textureRebuilt 事件
    • 当发现有重建的字体时,遍历场景中所有 Text 组件,使用 text.FontTextureChanged() 进行刷新
    private Font mNeedRefreshFont = null;
    
    void Start() {
        // 监听事件,记录发生重建的字体
        Font.textureRebuilt += delegate (Font font) {
            mNeedRefreshFont = font;
        }
    }
    
    void Update() {
        if (mNeedRefreshFont) {
            // 遍历所有相关字体组件进行刷新
            Text[] texts = GameObject.FindObjectsOfType<Text>();
            if (texts != null) {
                foreach (Text text in texts) {
                    if (text.font == mNeedRefreshFont) {
                        text.FontTextureChanged();
                    }
                }
            }
            mNeedRefreshFont = null;
        }
    }
    

4.1.6 Text Mesh Pro

  • 依然基于 TTF 字体库,但需要额外创建一个 Font Asset 对象(Window - TextMeshPro - Font Asset Creator)

  • TMP 使用的是 SDF 字体,与 Text 组件不同,它使用的是矢量图,图中保存的是文字边缘的距离场信息,这样不同字体大小的文字,也不会产生额外的内存开销。下图是 SDF 字体属性面板

    image.png

4.1.7 SDF(Signed Distance Field) 字体

  • 意为有向距离场。和 Text 的字符纹理位图原理不太一样,SDF 保的是字符每个点相对于字符边缘的距离

4.1.8 图文混排

步骤:

  1. 准备精灵图集,在 Sprite Editor 中将锚点设为“左下”后点击“slice”进行切片

    image.png

  2. 在 project 视图中选中精灵图集,鼠标右键菜单 Create - TextMeshPro - SpriteAsset,创建对应的 SpriteAsset 文件

    image.png

  3. 将上面创建的 SpriteAsset 文件拖到 TMP 组件的 Sprite Asset 栏中

    image.png

  4. 输入图片混排内容。如“Sample<sprite name=emoji_0>Text”

效果

image.png

4.1.9 样式

  • 用来对很长的 TMP 标签做简化。Create - Text Mesh Pro - Style Sheets

4.1.10 文字 Fallback

  • 若 TTF 字体库里没有想要的字体,就会从 fallback 字体列表中搜索

4.1.11 点击事件

  • <link="ID"> 标签可添加一个特殊的 ID,点击它时可触发事件
public class TestSciprt : MonoBehaviour, IPointerClickHandler {
    TextMeshProUGUI tmp;
    
    public void OnPointerClick(PointerEventData evt) {
        int linkIdx = TMP_TextUtilities.FindIntersectingLink(tmp, evt.position, null);
        if (linkIdx != -1) {
            TMP_LinkInfo linkInfo = tmp.textInfo.linkInfo[linkIdx];
            // 输出点击的标签名
            Debug.Log($"LinkID: {linkInfo.GetLinkID()}");
        }
    }
}

4.1.12 Image 组件

  • 当 Image Type 为 Simple 时,Use Sprite Mesh 复选框表示是否使用精灵网格来渲染

    image.png

    • 优点:忽略透明区域,提升 UI 的填充效率(减少片元着色器开销)

    • 缺点:增加了顶点数(增加顶点着色器开销)

    • 前提:纹理图的 Mesh Type 需设置为 Tight

      image.png

4.1.13 Raw Image 组件

  • 只能显示 Texture

4.1.14 Button 组件

4.1.15 Toggle 组件

4.1.16 Scroll View 组件

4.1.17 其他组件

  • Slider
  • ScrollBar
  • Dropdown
  • InputField

4.2 界面布局

4.2.1 Rect Transform 组件

4.2.2 拉伸

  • 给 Image 添加 Aspect Ratio Fitter 组件,Aspect Mode 设置为 Envelope Parent

4.2.3 自动布局

4.2.4 文本自适应

  • 当 Layout 中有文本时,文本需要处理一下才能正常参与布局
  • 给文本组件添加 Content Size Fitter 组件
  • 想自动换行的话,还要添加 Layout Element 组件

4.2.5 Layout Element 组件

4.2.6 Layout Group 组件

4.2.7 Content Size Fitter 组件

  • 可根据所有子节点的总体宽高来控制父节点的尺寸
  • 更新尺寸时机:子节点有进行更新(数量、尺寸)的帧最后
    IEnumerator AddChildren() {
        for (int i = 0; i < 9; i++) {
            Instantiate<GameObject>(image.gameObject, content);
            Debug.Log($"Before content size update: content.sizeDelta");
            yield return new WaitForEndOfFrame();
            Debug.Log($"After content size update: content.sizeDelta"); // 此时能得到 content 正确的尺寸
        }
    }
    
  • 若想在帧内立即得到正确的尺寸,需要调用强制刷新接口:LayoutRebuilder.ForceRebuildLayoutImmediate。但这样会影响性能,导致一帧中更新 2 次尺寸

4.3 Canvas 组件

  • 所有 UI 元素都要放在 Canvas 对象下面
  • 支持 3 种绘制方式:Overlay、Camera 和 World Space

4.3.1 UI 摄像机

步骤:

  1. 添加新的 Camera 对象,命名为 UICamera

  2. Culling Mask 只保留 UI

    image.png

  3. Render Type 选择 Overlay (需要叠加在一个 Base 摄像机之上)

  4. 将 UICamera 添加到主摄像机的 Stack 列表中

    image.png

  5. 将 Canvas 的 Render Mode 设为 Screen Space - Camera

  6. 将 UICamera 拖到 Canvas 的 Render Camera 中

    image.png

4.3.2 3D 界面

Screen Space(屏幕空间模拟 3D 界面):

  • 原理:基于 Canvas 的缩放技术实现,当屏幕自适应后,这种界面也会进行自适应
  • 将 UICamera 的 Projection 改为 Perspective 即可

World Space(真 3D 界面):

  • 原理:基于世界空间的位置,不会根据屏幕分辨率进行自适应

4.3.3 自适应 UI

  • 需配合 Canvas Scaler 组件使用

    • UI Scale Mode = 游戏通常使用 Scale With Screen Size
    • Screen Match Mode = Match Width Or Height
    • Match
      • 横屏 = 0(优先适配宽度)
      • 竖屏 = 1(优先适配高度)
  • 背景图的自适应要配合 Aspect Ratio Fitter 组件实现

    image.png

4.3.4 Canvas 与 3D 排序

  • 同一个 Canvas 下只要有一个元素发生变化,整个 Canvas 的 Mesh 都需要重构
  • 出于性能考虑,应将频繁变化和不频繁变化的 UI 分别放在不同的 Canvas 中——在父 Canvas 下创建一个子 Canvas

让 3D 对象在两 UI 元素之间显示,步骤:

  1. 创建材质,将 Surface Type 改为 Transparent(半透明对象不写深度,和 UI 一起进行排序)

  2. 将材质挂到 3D 对象上

  3. 给 3D 对象挂脚本,然后将 Sorting Order 改为 1

    class UISorting : MonoBehaviour
    {
        public int sortingOrder;
    
        void Awake()
        {
            var renderer = GetComponent<Renderer>();
            if (renderer)
                renderer.sortingOrder = sortingOrder;
        }
    }
    

    image.png

  4. 想挡住 3D 对象的 UI 元素,给它挂 Canvas 组件

  5. 修改其 Canvas 组件的 Sorting Order 为 2

    image.png

运行效果

image.png

让粒子特效对象在两 UI 元素之间显示,同理

  • 在粒子特效对象的 Renderer 栏中,修改 Order in Layer 的值

    image.png

运行效果

image.png

Unity 优先使用 Sorting Order 来对半透明物体进行排序,如果 Sorting Order 相同的话,可通过 Shader 中的 Render Queue 来指定渲染顺序,半透明默认层级为 3000

4.3.5 裁剪

Mask 裁剪,步骤:

  1. 给 UI 对象添加 Mask 组件
  2. 将需要裁剪的 UI 对象放到挂了 Mask 组件的对象中

效果

image.png

  • 优点:可以裁剪任意形状;缺点:Mask 需额外占用一个 Draw Call

矩形裁剪

  • 原理:将超出裁剪区域的像素 Alpha 改为 0

效果

image.png

4.3.6 裁剪粒子

4.4 Atlas

  • 把多张图片合在一个图片中,可减少 Draw Call

4.4.1 创建

  • 先确认 Sprite Packer 已启用。Project Setting - Editor 中

    image.png

  • Project 视图中右键 Create - 2D - Sprite Atlas

  • 将图片/图片文件夹拖到 Sprite Atlas 的 Objects for Packing 列表中

  • 点击 Pack Preview 按钮

4.4.2 读取 Atlas

  • 需要为 Image 元素更换 Sprite 时,需要先读取图集
    var atlas = Resources.Load<SpriteAtlas>("atlas"); // atlas 资源需放在 Resources 目录中
    var sprite = atlas.GetSprite("Sprites_2");
    GetComponent<Image>().sprite = sprite;
    

4.4.3 Variant

image.png

  • 好处:
    • 只使用变体图集,根据情况切换主图集(适用场景
      • 多风格 / 多版本 / 多质量的资源切换
    • 根据情况切换使用不同的变体图集(适用场景
      • 一套主图集,多套分辨率变体

4.4.4 监听加载事件

手动加载图集

  1. 取消勾选图集的 Include in Build 勾选
  2. 将图集中的 sprite 赋给 Image
  3. 运行时 Image 试图显示 sprite,从而会触发 SpriteAtlasManager.atlasRequested 和 SpriteAtlasManager.atlasRegistered 事件
    void OnEnable()
    {
        SpriteAtlasManager.atlasRequested += OnAtlasRequested;
        SpriteAtlasManager.atlasRegistered += OnAtlasRegistered;
    }
    
    void OnDisable()
    {
        SpriteAtlasManager.atlasRequested -= OnAtlasRequested;
        SpriteAtlasManager.atlasRegistered -= OnAtlasRegistered;
    }
    
    private void OnAtlasRegistered(SpriteAtlas atlas)
    {
        Debug.Log($"{atlas} 加载完成");
    }
    
    private void OnAtlasRequested(string atlas, Action<SpriteAtlas> action)
    {
        Debug.Log($"{atlas} 开始加载");
        action(Resources.Load<SpriteAtlas>(atlas));
    }
    

4.4.5 多囹集管理

原则:

  • 将 UI 动静分离

方法:

  • 给频繁发生改变的 UI 套上 Canvas
  • 尽量把复用性很强的图片放在一个公共图集中
  • 每个 UI 系统可有自己的图集
  • 将战斗下的 UI 合到一个图集中
  • 动态加载的图标(如技能、物品)不适合放在图集中

4.5 事件系统

  • UGUI 所有事件都依赖 EventSystem 组件完成的

4.5.1 Graphic Raycaster 组件

  • 若 Canvas (作为子 Canvas 也一样)下的 UI 元素需要添加点击事件,就必须挂载这个组件

4.5.2 UI 事件

4.5.3 UI 事件管理

  • 给每个元素都添加一个脚本来处理 UI 事件不太现实
  • 每个 UI 界面都应该有一个类来统一处理它下面所有元素的 UI 事件
    public class UIEvent : MonoBehaviour
    {
        public Image img1;
        public Button btn1;
    
        void Awake()
        {
            // 按钮有提供点击事件回调绑定接口
            btn1.onClick.AddListener(
                delegate()
                {
                    OnUIClick(btn1.gameObject);
                }
            );
            // 图片和文本没提供点击接口,需要自行实现
            UGUIEventListener.Get(img1.gameObject).onClick = OnUIClick;
        }
    
        private void OnUIClick(GameObject arg0)
        {
            Debug.Log($">>> {arg0}");
        }
    
        // 给图片/文本绑定的事件触发器
        class UGUIEventListener : EventTrigger
        {
            public UnityAction<GameObject> onClick;
    
            public override void OnPointerClick(PointerEventData eventData)
            {
                base.OnPointerClick(eventData);
    
                onClick?.Invoke(gameObject);
            }
    
            public static UGUIEventListener Get(GameObject go)
            {
                UGUIEventListener listener = go.GetComponent<UGUIEventListener>();
                listener = listener != null ? listener : go.AddComponent<UGUIEventListener>();
    
                return listener;
            }
        }
    }
    

4.5.4 UnityAction 和 UnityEvent

  • UnityAction 是 Unity 自己实现的事件传递系统,像委托和事件一样,可把方法传递到另一个实例中执行
    • 只能调用自己(使用 Action.Invoke 方法)
  • UnityEvent 则负责管理 UnityAction,提供了 AddListener、RemoveListener 和 RemoveAllListener 方法
    • 可绑定多个 UnityAction(使用 Event.Invoke 方法)

4.5.5 C# 事件系统

  • C# 提供了一组默认委托
    • Action 不带返回值
    • Func 可带返回值
  • C# 还提供了 event 关键字,推荐使用 += 来操作委托,避免覆盖

4.5.6 3D 事件

通过 PhysicsRaycaster 组件将 3D 对象的点击事件整合到 UGUI 的事件系统中

  • 场景中有 EventSystem 对象
  • Main Camera 绑定 Physics Raycaster 组件
  • 确认 3D 对象有挂载碰撞器组件,且没被 Raycast Target 为 True 的 UI 对象遮挡

4.5.7 K 帧动画

4.5.8 使用 Scroll Rect 组件制作游戏摇杆

4.5.9 点击区域优化(未明)

4.5.10 显示帧率

  • 公式:时间内执行 Update 的次数 / 时间(S)
  • 时间统计需要用 Time.unscaledDeltaTime

4.5.11 查看 UGUI 源代码