【实战精选面试题】Unity精选面试题详细解答,按模块整理汇总

  • 【实战精选面试题】Unity精选面试题详细解答,按模块整理汇总
    • [一、Unity 基础篇](#一、Unity 基础篇)
      • [1. Unity 中 new 一个普通 C# 对象,和实例化一个预制体,本质的区别是什么?](# 对象,和实例化一个预制体,本质的区别是什么?)
      • [2. 请解释一下 Unity 中假 NULL 现象,为什么 UnityEngine.Object 被销毁后还能触发一些看起来奇怪的判空行为?](#2. 请解释一下 Unity 中假 NULL 现象,为什么 UnityEngine.Object 被销毁后还能触发一些看起来奇怪的判空行为?)
      • [3. 请你解释一下 Awake、Start、OnEnable、OnDisable、OnDestroy 在对象池场景下可能遇到的问题。](#3. 请你解释一下 Awake、Start、OnEnable、OnDisable、OnDestroy 在对象池场景下可能遇到的问题。)
      • [4. RGBA32、RGB24、Alpha8、R8、R16 分别代表什么?](#4. RGBA32、RGB24、Alpha8、R8、R16 分别代表什么?)
      • [5. 有两个物理对象 A 和 B,希望在 Unity 中忽略这两个物体之间的碰撞应该怎么处理?只是他们两个之间不被碰撞,可能他们会和其他对象碰撞。](#5. 有两个物理对象 A 和 B,希望在 Unity 中忽略这两个物体之间的碰撞应该怎么处理?只是他们两个之间不被碰撞,可能他们会和其他对象碰撞。)
      • [6. 一个没有写 Update 的 MonoBehaviour 实例,它的 Update 会被调用吗?底层实现机制是什么?](#6. 一个没有写 Update 的 MonoBehaviour 实例,它的 Update 会被调用吗?底层实现机制是什么?)
      • [7. Unity 中的 MonoBehaviour 为什么不能我们自己 new?](#7. Unity 中的 MonoBehaviour 为什么不能我们自己 new?)
      • [8. 如何实现一个对象始终面向摄像机(请尽可能多的说出实现方案)?](#8. 如何实现一个对象始终面向摄像机(请尽可能多的说出实现方案)?)
      • [9. Unity 中是否可以使用 C++ 编写好的代码?](#9. Unity 中是否可以使用 C++ 编写好的代码?)
      • [10. Unity 中的 Mesh Filter(网格过滤器)组件是用来做什么的?](#10. Unity 中的 Mesh Filter(网格过滤器)组件是用来做什么的?)
      • [11. Unity 中 Skinned Mesh Renderer(蒙皮网格渲染器)和 Mesh Renderer(网格渲染器)的区别是什么?](#11. Unity 中 Skinned Mesh Renderer(蒙皮网格渲染器)和 Mesh Renderer(网格渲染器)的区别是什么?)
      • [12. Unity 中 Material(材质)的作用是什么?](#12. Unity 中 Material(材质)的作用是什么?)
      • [13. Unity 中 Shader(着色器)的作用是什么?](#13. Unity 中 Shader(着色器)的作用是什么?)
      • [14. Unity 生命周期函数中在整个生命周期中可能会被反复执行的有哪些?(至少说出 5 个)](#14. Unity 生命周期函数中在整个生命周期中可能会被反复执行的有哪些?(至少说出 5 个))
      • [15. Unity 中如果想要改变物理系统中默认重力的方向或大小,应该在哪里修改?](#15. Unity 中如果想要改变物理系统中默认重力的方向或大小,应该在哪里修改?)
      • [16. Transform 中的本地坐标系的方向 转换为相对世界坐标系的方向 的两个 API TransformVector () 和 TransformDirection () 有什么区别?](#16. Transform 中的本地坐标系的方向 转换为相对世界坐标系的方向 的两个 API TransformVector () 和 TransformDirection () 有什么区别?)
      • [17. 该 Unity 中的报错是什么意思?如何定位该问题?我们一般如何排查解决该问题?(至少说出 2 种方案)(报错信息:NullReferenceException: Object reference not set to an instance of an object Test.Start () (at Assets/Scripts/Test.cs:11))](#17. 该 Unity 中的报错是什么意思?如何定位该问题?我们一般如何排查解决该问题?(至少说出 2 种方案)(报错信息:NullReferenceException: Object reference not set to an instance of an object Test.Start () (at Assets/Scripts/Test.cs:11)))
      • [18. IK(逆运动学)可以帮助我们完成什么功能?](#18. IK(逆运动学)可以帮助我们完成什么功能?)
      • [19. Unity 中为 Sprite 前后关系排序的常用方式有哪些?(至少说出 3 种)](#19. Unity 中为 Sprite 前后关系排序的常用方式有哪些?(至少说出 3 种))
      • [20. Unity 在发布 Android 平台项目时,在加载Application.streamingAssetsPath中文件时我们应该注意什么?](#20. Unity 在发布 Android 平台项目时,在加载Application.streamingAssetsPath中文件时我们应该注意什么?)
      • [21. Unity 生命周期函数中的 OnEnable 和 Start,我们在使用时应该如何选择?可以举例说明。](#21. Unity 生命周期函数中的 OnEnable 和 Start,我们在使用时应该如何选择?可以举例说明。)
      • [22. 如果在 Unity 当中制作 FPS 游戏,如何模拟枪械开枪时的后坐力?](#22. 如果在 Unity 当中制作 FPS 游戏,如何模拟枪械开枪时的后坐力?)
      • [23. Unity 中 `Application.persistentDataPath` 和 `Application.streamingAssetsPath` 的区别是什么?](#23. Unity 中 Application.persistentDataPath 和 Application.streamingAssetsPath 的区别是什么?)
      • [24. Unity 中 `Input` 系统的新旧版本有什么区别?何时使用新版 Input System?](#24. Unity 中 Input 系统的新旧版本有什么区别?何时使用新版 Input System?)
      • [25. Unity 中 `Animation` 组件和 `Animator` 组件的主要区别是什么?](#25. Unity 中 Animation 组件和 Animator 组件的主要区别是什么?)
      • [26. Unity 中 `Camera` 的 `Clear Flags` 和 `Culling Mask` 分别有什么作用?](#26. Unity 中 Camera 的 Clear Flags 和 Culling Mask 分别有什么作用?)
      • [27. Unity 中 `Physics.Raycast` 和 `Physics.SphereCast` 的区别及使用场景是什么?](#27. Unity 中 Physics.Raycast 和 Physics.SphereCast 的区别及使用场景是什么?)
      • [28. 点乘叉乘](#28. 点乘叉乘)
      • [29. 四元数(Quaternion)和欧拉角有什么区别?什么是万向锁(Gimbal Lock)?](#29. 四元数(Quaternion)和欧拉角有什么区别?什么是万向锁(Gimbal Lock)?)
      • [30. Lerp、Slerp、Nlerp 的区别是什么?](#30. Lerp、Slerp、Nlerp 的区别是什么?)
      • [31. FixedUpdate 和物理时间步长(Fixed Timestep)的关系?为什么物理模拟要放在 FixedUpdate?](#31. FixedUpdate 和物理时间步长(Fixed Timestep)的关系?为什么物理模拟要放在 FixedUpdate?)
      • [32. CharacterController 和 Rigidbody 有什么区别?分别在什么场景使用?](#32. CharacterController 和 Rigidbody 有什么区别?分别在什么场景使用?)
      • [33. 什么是连续碰撞检测(Continuous Collision Detection,CCD)?什么时候需要开启?](#33. 什么是连续碰撞检测(Continuous Collision Detection,CCD)?什么时候需要开启?)
      • [34. 物理层(Layer)和碰撞矩阵如何优化性能?](#34. 物理层(Layer)和碰撞矩阵如何优化性能?)
    • 二、进阶篇
      • [1. Unity 中 SerializeReference(序列化引用)特性的用途是什么?](#1. Unity 中 SerializeReference(序列化引用)特性的用途是什么?)
      • [2. Unity 中生命周期函数被调用的底层原理是什么?](#2. Unity 中生命周期函数被调用的底层原理是什么?)
      • [3. 在 Android 平台上运行的项目出现问题,我们有几种调试的方式?(至少说出 3 种方案,不使用第三方插件)](#3. 在 Android 平台上运行的项目出现问题,我们有几种调试的方式?(至少说出 3 种方案,不使用第三方插件))
      • [4. Unity 当中存在多线程时,继承 MonoBehaviour 的脚本是否有必要对其中内容加锁?为什么?](#4. Unity 当中存在多线程时,继承 MonoBehaviour 的脚本是否有必要对其中内容加锁?为什么?)
      • [5. 我们在 Unity 中进行一些复杂逻辑处理时,比如网路通讯、寻路算法,往往会开启多线程进行处理。我们如何保证数据能够和 Unity 主线程进行正常交互?(请至少说出 1 种方式)](#5. 我们在 Unity 中进行一些复杂逻辑处理时,比如网路通讯、寻路算法,往往会开启多线程进行处理。我们如何保证数据能够和 Unity 主线程进行正常交互?(请至少说出 1 种方式))
      • [6. Unity 中协程(Coroutine)的原理是什么?使用时有哪些注意事项?](#6. Unity 中协程(Coroutine)的原理是什么?使用时有哪些注意事项?)
      • [7. Unity 中 `ScriptableObject` 的用途和优势是什么?](#7. Unity 中 ScriptableObject 的用途和优势是什么?)
      • [8. Unity 中 `AssetPostprocessor` 是什么?有哪些应用场景?](#8. Unity 中 AssetPostprocessor 是什么?有哪些应用场景?)
      • [9. Unity 中 `EditorWindow` 的用途和开发流程是什么?](#9. Unity 中 EditorWindow 的用途和开发流程是什么?)
      • [10. A* 算法是什么?实现原理是什么](#10. A* 算法是什么?实现原理是什么)
      • [11. Dictionary 底层原理,如何解决哈希冲突](#11. Dictionary 底层原理,如何解决哈希冲突)
      • 12. C#中的迭代器是什么?在使用时需要注意哪些内容
      • [13. 讲一下ECS是什么,怎样使用以及优缺点](#13. 讲一下ECS是什么,怎样使用以及优缺点)
      • [14. 游戏中的新手引导一般怎样设计,有哪些需要注意的地方](#14. 游戏中的新手引导一般怎样设计,有哪些需要注意的地方)
      • [15. 游戏中的换装系统如何实现](#15. 游戏中的换装系统如何实现)
      • [16. 游戏中的红点系统怎样设计](#16. 游戏中的红点系统怎样设计)
      • [17. Excel导出的表,有哪些方案可以优化](#17. Excel导出的表,有哪些方案可以优化)
      • [18. Unity中接SDK,一般怎样通信](#18. Unity中接SDK,一般怎样通信)
      • [19. Unity 中单例(Singleton)的正确写法有哪些?各有什么注意点?](#19. Unity 中单例(Singleton)的正确写法有哪些?各有什么注意点?)
      • [20. 观察者模式 / 事件系统在 Unity 中如何实现?](#20. 观察者模式 / 事件系统在 Unity 中如何实现?)
      • [21. 状态机模式(State Machine)在游戏中的用途和实现?](#21. 状态机模式(State Machine)在游戏中的用途和实现?)
      • [22. MVC、MVP、MVVM 三种 UI 架构的区别?Unity 中如何选择?](#22. MVC、MVP、MVVM 三种 UI 架构的区别?Unity 中如何选择?)
      • [23. 工厂模式(Factory Pattern)在游戏开发中的用途?](#23. 工厂模式(Factory Pattern)在游戏开发中的用途?)
      • [24. 依赖注入(Dependency Injection)是什么?Unity 中用什么实现?](#24. 依赖注入(Dependency Injection)是什么?Unity 中用什么实现?)
      • [25. 什么是二叉堆(Binary Heap)?它在游戏开发中有什么用途?](#25. 什么是二叉堆(Binary Heap)?它在游戏开发中有什么用途?)
      • [26. 什么是 LRU(Least Recently Used)缓存?如何实现?](#26. 什么是 LRU(Least Recently Used)缓存?如何实现?)
      • [27. 常见的排序算法有哪些?各自的时间复杂度和适用场景?](#27. 常见的排序算法有哪些?各自的时间复杂度和适用场景?)
      • [28. 图的遍历(BFS 和 DFS)在游戏开发中的应用?](#28. 图的遍历(BFS 和 DFS)在游戏开发中的应用?)
      • [29. C# 中值类型(struct)和引用类型(class)的区别?什么场景用 struct?](# 中值类型(struct)和引用类型(class)的区别?什么场景用 struct?)
      • [30. C# 的 GC 分代机制是什么?什么是大对象堆(LOH)?](# 的 GC 分代机制是什么?什么是大对象堆(LOH)?)
      • [31. C# 中委托(delegate)、事件(event)和闭包(closure)的内存模型和注意事项?](# 中委托(delegate)、事件(event)和闭包(closure)的内存模型和注意事项?)
      • [32. 如何在 Unity 中用 struct 做内存优化?有什么陷阱?](#32. 如何在 Unity 中用 struct 做内存优化?有什么陷阱?)
    • [三、UI / UGUI 篇](#三、UI / UGUI 篇)
      • [1. 一个 UI 页面打开时明显卡顿,从 Profiler 里看到 Canvas.BuildBatch 和 Layout.Rebuild 很高,你会怎么分析?](#1. 一个 UI 页面打开时明显卡顿,从 Profiler 里看到 Canvas.BuildBatch 和 Layout.Rebuild 很高,你会怎么分析?)
      • [2. Unity 中 UGUI 优化相关注意点有哪些?(至少说出 3 点)](#2. Unity 中 UGUI 优化相关注意点有哪些?(至少说出 3 点))
      • [3. UGUI 渲染过程中相机裁切(裁剪)在哪一步?](#3. UGUI 渲染过程中相机裁切(裁剪)在哪一步?)
      • [4. 如何在 UGUI 中处理不同分辨率和屏幕比例的适配?](#4. 如何在 UGUI 中处理不同分辨率和屏幕比例的适配?)
      • [5. UGUI 中如何做到点击按钮时,不要响应其他 Input 相关的检测?](#5. UGUI 中如何做到点击按钮时,不要响应其他 Input 相关的检测?)
      • [6. 如何为 UGUI 中的某一个控件添加自定义事件监听(比如为一个 Image 添加点击事件)?](#6. 如何为 UGUI 中的某一个控件添加自定义事件监听(比如为一个 Image 添加点击事件)?)
      • [7. 我们在进行 UI 开发时,每个面板都会有很多控件(Button、Toggle、Slider 等等),每新写一个面板逻辑,都会为这些控件做一些相同的事情,比如:声明控件、查找控件、监听控件等等。请问:我们应该如何提升我们的开发效率,让这些事情不用每次都去做?(至少说出两种方案)](#7. 我们在进行 UI 开发时,每个面板都会有很多控件(Button、Toggle、Slider 等等),每新写一个面板逻辑,都会为这些控件做一些相同的事情,比如:声明控件、查找控件、监听控件等等。请问:我们应该如何提升我们的开发效率,让这些事情不用每次都去做?(至少说出两种方案))
      • [8. Unity 中 UI 的 DrawCall 合批原理是什么?哪些因素会破坏合批?](#8. Unity 中 UI 的 DrawCall 合批原理是什么?哪些因素会破坏合批?)
      • [9. Unity 中 `Transform` 的 `localPosition` 和 `RectTransform` 的 `anchoredPosition` 有什么区别?](#9. Unity 中 Transform 的 localPosition 和 RectTransform 的 anchoredPosition 有什么区别?)
      • [10. Image / RawImage 区别](#10. Image / RawImage 区别)
      • [11. LayoutGroup(Horizontal/Vertical/GridLayoutGroup)性能有哪些坑需要注意](#11. LayoutGroup(Horizontal/Vertical/GridLayoutGroup)性能有哪些坑需要注意)
    • 四、渲染篇
      • [1. 如何优化光带来的开销?](#1. 如何优化光带来的开销?)
      • [2. DrawCall、SetPass、Overdraw、Fillrate (填充率) 分别是什么?优化手段有哪些?](#2. DrawCall、SetPass、Overdraw、Fillrate (填充率) 分别是什么?优化手段有哪些?)
      • [3. Static Batching、Dynamic Batching、GPU Instancing、SRP Batcher 各自的限制是什么?](#3. Static Batching、Dynamic Batching、GPU Instancing、SRP Batcher 各自的限制是什么?)
      • [4. 简单解释深度测试、深度写入、透明排序为什么容易出渲染问题?](#4. 简单解释深度测试、深度写入、透明排序为什么容易出渲染问题?)
      • [5. Shader 和 Compute Shader 的区别是什么?](#5. Shader 和 Compute Shader 的区别是什么?)
      • [6. 纹理中通道合并指的是什么?](#6. 纹理中通道合并指的是什么?)
      • [7. 光照探针有什么作用?](#7. 光照探针有什么作用?)
      • [8. 反射探针有什么作用?](#8. 反射探针有什么作用?)
      • [9. 光照烘焙时混合光源类型的作用是什么?](#9. 光照烘焙时混合光源类型的作用是什么?)
      • [10. Shader 要怎么写才能更省 GPU(至少说出 3 点)?](#10. Shader 要怎么写才能更省 GPU(至少说出 3 点)?)
      • [11. 想要降低 GPU 处理的顶点数据有几种方式?(至少说出 3 种方式)](#11. 想要降低 GPU 处理的顶点数据有几种方式?(至少说出 3 种方式))
      • [12. URP 项目中 SRP Batcher 和 GPU Instancing 两种 DrawCall 优化方案应该如何选择?](#12. URP 项目中 SRP Batcher 和 GPU Instancing 两种 DrawCall 优化方案应该如何选择?)
      • [13. 动态批处理的限制条件是什么?至少说出 3 点。](#13. 动态批处理的限制条件是什么?至少说出 3 点。)
      • [14. GPU Instancing 的原理是什么?](#14. GPU Instancing 的原理是什么?)
      • [15. 为什么 URP 管线在移动平台上表现比内置渲染管线好?至少说出 3 点。](#15. 为什么 URP 管线在移动平台上表现比内置渲染管线好?至少说出 3 点。)
      • [16. 当一个场景中有大量相同的动态对象(如 1000 只敌人),如何优化它们的渲染?](#16. 当一个场景中有大量相同的动态对象(如 1000 只敌人),如何优化它们的渲染?)
      • [17. Unity Shader 中如何实现水面效果?2D 和 3D 的分别如何实现?](#17. Unity Shader 中如何实现水面效果?2D 和 3D 的分别如何实现?)
      • [18. Unity 中前向渲染路径、顶点照明渲染路径、延迟渲染路径各自的优缺点是什么?](#18. Unity 中前向渲染路径、顶点照明渲染路径、延迟渲染路径各自的优缺点是什么?)
      • [19. Unity 中动态批处理和静态批处理的区别?](#19. Unity 中动态批处理和静态批处理的区别?)
      • [20. Unity 发布后的应用程序的美术表现效果比在编辑器中看起来差,可能原因有哪些?](#20. Unity 发布后的应用程序的美术表现效果比在编辑器中看起来差,可能原因有哪些?)
      • [21. Unity 中线性颜色空间和伽马颜色空间的主要区别是什么?](#21. Unity 中线性颜色空间和伽马颜色空间的主要区别是什么?)
      • [22. 为什么线性的表现效果更好,Unity 默认还是使用的伽马呢?](#22. 为什么线性的表现效果更好,Unity 默认还是使用的伽马呢?)
      • [23. Shader 和材质球的关系是什么?](#23. Shader 和材质球的关系是什么?)
      • [24. 什么是纹理映射?](#24. 什么是纹理映射?)
      • [25. 请问为什么延迟渲染路径能够优化有大量光源的场景渲染?](#25. 请问为什么延迟渲染路径能够优化有大量光源的场景渲染?)
      • [26. 法线贴图(Normal Map)为什么要使用切线空间(Tangent Space)?](#26. 法线贴图(Normal Map)为什么要使用切线空间(Tangent Space)?)
      • [27. 什么是投影矩阵?透视投影和正交投影有什么区别?](#27. 什么是投影矩阵?透视投影和正交投影有什么区别?)
    • 五、性能篇
      • [1. 在 Unity 中哪些写法容易产生 GC Alloc?](#1. 在 Unity 中哪些写法容易产生 GC Alloc?)
      • [2. 在移动设备上运行项目帧率不稳,你会如何一步步定位和优化问题?](#2. 在移动设备上运行项目帧率不稳,你会如何一步步定位和优化问题?)
      • [3. 要做一个关卡加载与进战斗的预加载系统,目标是进入战斗后 0 卡顿。你会如何设计预加载?](#3. 要做一个关卡加载与进战斗的预加载系统,目标是进入战斗后 0 卡顿。你会如何设计预加载?)
      • [4. 如何优化动画计算带来的 CPU 开销?](#4. 如何优化动画计算带来的 CPU 开销?)
      • [5. Texture 的内存怎么估算?比如一张 2048×2048 的 RGBA32 纹理大概占多少内存?](#5. Texture 的内存怎么估算?比如一张 2048×2048 的 RGBA32 纹理大概占多少内存?)
      • [6. 纹理大小如何优化?](#6. 纹理大小如何优化?)
      • [7. 模型内存怎么估算?音频内存怎么估算?](#7. 模型内存怎么估算?音频内存怎么估算?)
      • [8. 怎么区分内存占用和显存占用(什么东西存在内存中,什么东西存在显存中)?](#8. 怎么区分内存占用和显存占用(什么东西存在内存中,什么东西存在显存中)?)
      • [9. 对象(缓存)池是减少内存还是增加内存?](#9. 对象(缓存)池是减少内存还是增加内存?)
      • [10. 图集是否更节省内存?](#10. 图集是否更节省内存?)
      • [11. 如何做一次上线前的内存预算与压测?](#11. 如何做一次上线前的内存预算与压测?)
      • [12. 音频的加载类型中的 Decompress On Load(加载时解压)、Compressed In Memory(在内存中压缩)、Streaming(流式加载)有什么区别?](#12. 音频的加载类型中的 Decompress On Load(加载时解压)、Compressed In Memory(在内存中压缩)、Streaming(流式加载)有什么区别?)
      • [13. Unity 中在选择纹理压缩格式时 Compressed(压缩)和 Crunched(紧缩)格式的区别?](#13. Unity 中在选择纹理压缩格式时 Compressed(压缩)和 Crunched(紧缩)格式的区别?)
      • [14. 为什么 Unity 中使用了异步加载仍然可能卡顿?可能原因是什么?如何解决?](#14. 为什么 Unity 中使用了异步加载仍然可能卡顿?可能原因是什么?如何解决?)
      • [15. 如何优化异步加载后实例化对象带来的卡顿呢?](#15. 如何优化异步加载后实例化对象带来的卡顿呢?)
      • [16. 缓存池(对象池)为什么可以优化性能?](#16. 缓存池(对象池)为什么可以优化性能?)
      • [17. Animator 分层动画,当挂载 Animator 组件的物体非常多时,会产生哪些性能问题?](#17. Animator 分层动画,当挂载 Animator 组件的物体非常多时,会产生哪些性能问题?)
      • [18. Unity 中除了游戏场景上的对象可以使用对象池,还有哪些内容可以使用?](#18. Unity 中除了游戏场景上的对象可以使用对象池,还有哪些内容可以使用?)
      • [19. 在手机移动设备上,如何优化 Unity 场景的顶点数?](#19. 在手机移动设备上,如何优化 Unity 场景的顶点数?)
      • [20. 如何在 Unity 当中避免内存泄漏的性能问题?](#20. 如何在 Unity 当中避免内存泄漏的性能问题?)
      • [21. 如何在 Unity 当中避免由于对象过多导致的性能问题?](#21. 如何在 Unity 当中避免由于对象过多导致的性能问题?)
      • [22. Unity 中如何显示性能消耗?(编辑器中、真机中)](#22. Unity 中如何显示性能消耗?(编辑器中、真机中))
      • [23. 请介绍一些在 Unity 中减少内存的方法。(至少说出 3 种方法)](#23. 请介绍一些在 Unity 中减少内存的方法。(至少说出 3 种方法))
      • [24. 请介绍一些在 Unity 中提升性能的方法(至少说出 5 种方法)。](#24. 请介绍一些在 Unity 中提升性能的方法(至少说出 5 种方法)。)
      • [25. 为什么要为缓存池中的单个池子设置对象上限?](#25. 为什么要为缓存池中的单个池子设置对象上限?)
      • [26. Unity 中安卓主流的纹理压缩格式 ETC 和 ETC2 的主要区别是什么?](#26. Unity 中安卓主流的纹理压缩格式 ETC 和 ETC2 的主要区别是什么?)
      • [27. Android 平台常用压缩格式 ETC 不支持透明通道,那我们的半透明纹理应该如何处理?](#27. Android 平台常用压缩格式 ETC 不支持透明通道,那我们的半透明纹理应该如何处理?)
      • [28. Unity 中针对 IOS 平台我们使用的主流纹理压缩格式是什么?](#28. Unity 中针对 IOS 平台我们使用的主流纹理压缩格式是什么?)
      • [29. Unity 中Instantiate 实例化游戏对象,主要消耗的是哪方面的性能,为什么](#29. Unity 中Instantiate 实例化游戏对象,主要消耗的是哪方面的性能,为什么)
      • [30. 内存泄漏是什么,怎么排查和解决](#30. 内存泄漏是什么,怎么排查和解决)
    • [六、热更新与 Lua 篇](#六、热更新与 Lua 篇)
      • [1. Unity 中 AB 包和 Resources 资源加载区别是什么?](#1. Unity 中 AB 包和 Resources 资源加载区别是什么?)
      • [2. 如果一个 AssetBundle 被多个 AB 包依赖,卸载依赖包时是否可以直接卸载它?如何处理相互引用的 AB 包?](#2. 如果一个 AssetBundle 被多个 AB 包依赖,卸载依赖包时是否可以直接卸载它?如何处理相互引用的 AB 包?)
      • [3. Unity 使用 Lua 热更新方案时,进行垃圾回收时需要注意什么?](#3. Unity 使用 Lua 热更新方案时,进行垃圾回收时需要注意什么?)
      • [4. 如果你要开发一个项目,Assetbundle 和 Addressables 你会如何选择?](#4. 如果你要开发一个项目,Assetbundle 和 Addressables 你会如何选择?)
      • [5. Unity 中 AssetBundle 的依赖管理应该如何正确处理?如何确保内存不泄漏?](#5. Unity 中 AssetBundle 的依赖管理应该如何正确处理?如何确保内存不泄漏?)
      • 6. Lua与C#交互原理介绍
      • [7. xLua性能优化](#7. xLua性能优化)
      • [8. Lua 上值 UpValue是什么](#8. Lua 上值 UpValue是什么)
      • [9. 什么是闭包,有哪些隐患](#9. 什么是闭包,有哪些隐患)
    • 七、网络篇
      • [1. 服务器权威 和 客户端权威区别?如何防作弊?](#1. 服务器权威 和 客户端权威区别?如何防作弊?)
      • [2. 使用 UnityWebRequest 加载音效文件,应该使用什么 API?](#2. 使用 UnityWebRequest 加载音效文件,应该使用什么 API?)
      • [3. 在 Unity 中,Protobuf(pb)的介绍 / 使用流程 / 优点](#3. 在 Unity 中,Protobuf(pb)的介绍 / 使用流程 / 优点)
      • [4. 游戏中如何实现断线重连机制?(断线重连的大概流程)](#4. 游戏中如何实现断线重连机制?(断线重连的大概流程))

前言

之前整理过一份十万字的面试大全,主要是搜集了网上的各种面试资料进行汇总整理而成:

2026年Unity面试题大全,共十万字面试题总结【收藏一篇足够面试,持续更新】

在本文的面试题中,相关答案由于篇幅原因不会深入讲解原理内容,会针对面试题目本身做一个精选回答,满足面试的一个合格答案。如果对某个面试题目感兴趣,可以针对该题目自行去深入了解即可。

内容则是最近在各个平台针对性的收集了一些真正面试题案例以及比较经典的面试题,相当于一个Unity面试题精选篇。哪怕不需要面试也可以根据本文对自己的能力进行查漏补缺,对某个方向进行深入研究,提升自己的综合能力。


【实战精选面试题】Unity精选面试题详细解答,按模块整理汇总


一、Unity 基础篇

1. Unity 中 new 一个普通 C# 对象,和实例化一个预制体,本质的区别是什么?

答案:

对比项 new 普通 C# 对象 Instantiate 预制体
内存分配 只在托管堆上分配内存 引擎内部分配内存,同时可能分配托管堆(组件)
生命周期 与普通 C# 对象相同,不受 Unity 生命周期管理 被 Unity 引擎管理,会触发 Awake、OnEnable 等生命周期
引擎集成 不关联 GameObject 或组件,仅作为数据容器 会创建 GameObject 及其所有组件,并添加到场景中
性能开销 较小,仅分配托管内存 较大,涉及克隆、序列化、组件初始化、渲染数据准备等
使用场景 存储数据、工具类、逻辑对象 生成游戏中的实体(角色、特效、UI 元素等)

本质区别:

  • new 只创建 C# 对象,不涉及 Unity 引擎的资源管理、渲染、物理等系统。
  • Instantiate 是引擎级别的操作,会复制预制体的所有组件、子对象,并为其分配 InstanceID,纳入场景管理,同时触发生命周期函数。

2. 请解释一下 Unity 中假 NULL 现象,为什么 UnityEngine.Object 被销毁后还能触发一些看起来奇怪的判空行为?

答案:

Unity 中的 UnityEngine.Object 重载了 == 和 != 运算符,导致对象被销毁后出现"假 NULL"现象。

原因:

  • 在 Unity 中,Destroy(obj) 会销毁原生对象(C++ 端),但 C# 端的托管引用仍然存在(不为 null)。
  • 重载的 == 运算符会检查原生对象是否存在,如果原生对象已销毁,即使 C# 引用不为 null,obj == null 也返回 true。

表现:

csharp 复制代码
GameObject go = new GameObject();
Destroy(go);
if (go == null) Debug.Log("go is null");  // 输出,因为重载的 == 返回 true
if (go is null) Debug.Log("go is null");  // 也输出,C# 7.0+ 的 is 也会使用重载
if (ReferenceEquals(go, null)) Debug.Log("Reference is null"); // 不输出,因为 C# 引用不为 null

为什么会出现"假 NULL"?

  • 因为 C# 引用本身不为 null,但引擎认为该对象已失效,所以 == 返回 true 方便开发者判断对象是否"可用"。

注意事项:

  • 不要使用 ReferenceEquals 判断 UnityEngine.Object 是否被销毁,应直接用 obj == null。
  • obj is null 会走重载的 == 运算符、可能返回 true,属于易踩坑点,实际项目统一用 obj == null 判断即可。
  • 在对象池中,当对象被放回池中时,通常不会调用 Destroy,因此 == null 不会返回 true,需要手动管理状态。

3. 请你解释一下 Awake、Start、OnEnable、OnDisable、OnDestroy 在对象池场景下可能遇到的问题。

答案:

对象池的核心是复用对象,而不是频繁创建销毁,这会导致生命周期函数的调用不符合预期。

函数 调用时机 对象池问题
Awake 对象第一次创建时调用一次 池中对象取出时不会再调用 Awake,可能导致状态未重置
Start 对象第一次启用时调用一次 同样只调用一次,再次从池中取出启用时不会调用
OnEnable 对象每次启用时调用 适合进行重置操作,但要注意多次调用可能重复添加事件监听
OnDisable 对象每次禁用时调用 适合进行清理操作,如取消事件订阅、重置状态
OnDestroy 对象真正销毁时调用 池中对象通常不会被销毁,所以 OnDestroy 不会调用,资源需手动管理

常见问题:

  • 在 Awake 或 Start 中引用了其他对象,但其他对象可能还未从池中取出。
  • 状态未在 OnDisable 中重置,导致下次取出时保留上次数据。
  • 事件监听未在 OnDisable 中移除,导致对象失活后仍然收到事件,产生报错或内存泄漏。

解决方案:

  • 统一将初始化逻辑放在 OnEnable 中,将清理逻辑放在 OnDisable 中。
  • 在 OnEnable 中重新获取组件引用,避免缓存过时的引用。
  • 使用 Reset 方法手动重置状态(可在 OnDisable 或从池中取出前调用)。

4. RGBA32、RGB24、Alpha8、R8、R16 分别代表什么?

答案:

这些是纹理的像素格式,表示每个像素占用多少位(bit):

格式 位深 说明
RGBA32 32 位 红、绿、蓝、Alpha 通道各 8 位,最常见的不透明+透明纹理格式。
RGB24 24 位 红、绿、蓝各 8 位,无透明通道。
Alpha8 8 位 只存储 Alpha 信息,常用于 Mask 或遮罩。
R8 8 位 单通道 8 位,存储如 Metallic 贴图。
R16 16 位 单通道 16 位,常用于高度图或法线贴图。

5. 有两个物理对象 A 和 B,希望在 Unity 中忽略这两个物体之间的碰撞应该怎么处理?只是他们两个之间不被碰撞,可能他们会和其他对象碰撞。

答案:

可以通过 物理层(Physics Layer) 和 碰撞矩阵(Collision Matrix) 来实现。

  1. 给 A 和 B 分配两个不同的 Layer,比如 LayerA 和 LayerB。
  2. 打开 Edit → Project Settings → Physics(如果是 2D 则选择 Physics 2D)。
  3. 在碰撞矩阵中,找到 LayerA 和 LayerB 相交的格子,取消勾选。这样,属于 LayerA 和 LayerB 的物体之间就不会再发生碰撞。
  4. 它们与其他 Layer 的碰撞关系由矩阵中其他格子决定,不受影响。

代码方式(运行时动态设置):

csharp 复制代码
Physics.IgnoreCollision(colliderA, colliderB, true);

注意事项:

  • 使用 Layer 矩阵方式适合全局配置,不会产生运行时开销。
  • 使用 IgnoreCollision 可以动态控制,但要注意在对象池复用时的重置。

6. 一个没有写 Update 的 MonoBehaviour 实例,它的 Update 会被调用吗?底层实现机制是什么?

答案:

不会 。Unity 的底层(C++ 引擎)会维护一个需要调用 Update 的脚本列表。如果一个脚本没有 Update 方法,C++ 端就不会将该脚本的实例添加到这个列表中。底层通过 消息机制 来实现:在 C# 脚本编译时,如果检测到 Update 方法,就会在 C++ 端注册一个对应的回调。因此,没有 Update 方法的脚本,其 Update 函数永远不会被调用,也不会产生任何开销。


7. Unity 中的 MonoBehaviour 为什么不能我们自己 new?

答案:

因为 MonoBehaviour 的实例必须由 Unity 引擎通过 Instantiate 或 AddComponent 创建。引擎需要在 C++ 层为每个 MonoBehaviour 分配一个对应的 InstanceID,并将其纳入引擎的生命周期管理(如场景序列化、消息分发、对象销毁等)。如果直接用 new 创建,C# 对象只是一个普通的托管对象,引擎无法追踪它,无法触发 Awake、Start 等生命周期函数,也无法将其与场景中的 GameObject 关联。


8. 如何实现一个对象始终面向摄像机(请尽可能多的说出实现方案)?

答案:

  1. Transform.LookAt

    csharp 复制代码
    transform.LookAt(Camera.main.transform);

    让物体的 forward 方向指向摄像机。常用于 Billboard 效果。

  2. Quaternion.LookRotation

    csharp 复制代码
    Vector3 direction = transform.position - Camera.main.transform.position;
    transform.rotation = Quaternion.LookRotation(direction);

    旋转物体使其 Z 轴朝向摄像机。

  3. 只在水平面内朝向(不上下倾斜)

    csharp 复制代码
    Vector3 targetPos = Camera.main.transform.position;
    targetPos.y = transform.position.y;
    transform.LookAt(targetPos);

    通过把目标点的 Y 对齐到自身 Y,使物体只在水平方向面向摄像机、不上下倾斜。

  4. 使用 Shader 实现 Billboard

    在 Vertex Shader 中旋转顶点,使平面始终面向摄像机。这种方式不改变 Transform,性能好,适合大量 Billboard 物体。

  5. Canvas 的 Billboard 模式

    对于 UI 元素,可以将 Canvas 的 Render Mode 设置为 World Space,然后旋转 Canvas 使其面向摄像机。

  6. 使用 Quaternion.FromToRotation

    csharp 复制代码
    Quaternion rotation = Quaternion.FromToRotation(Vector3.forward, direction);
    transform.rotation = rotation;

9. Unity 中是否可以使用 C++ 编写好的代码?

答案:

可以。Unity 支持通过 原生插件(Native Plugin) 的方式使用 C++ 代码。将 C++ 代码编译成动态链接库(.dll 或 .so 或 .a),然后在 Unity 中通过 [DllImport] 来调用。常用于:

  • 调用平台原生 API(如 iOS 的 StoreKit,Android 的 Java 代码)。
  • 高性能计算模块(如加密解密、物理计算)。
  • 复用已有的 C++ 代码库。

示例:

csharp 复制代码
[DllImport("MyNativePlugin")]
private static extern int AddNumbers(int a, int b);

10. Unity 中的 Mesh Filter(网格过滤器)组件是用来做什么的?

答案:

Mesh Filter 组件用于存储和引用一个网格(Mesh)资源。它本身不负责渲染,只提供网格数据。必须与 Mesh Renderer 组件配合使用,Mesh Renderer 会从 Mesh Filter 中获取网格数据,并使用材质对其进行渲染。


11. Unity 中 Skinned Mesh Renderer(蒙皮网格渲染器)和 Mesh Renderer(网格渲染器)的区别是什么?

答案:

组件 用途 特点
Mesh Renderer 渲染静态网格,网格的顶点位置是固定的 通常用于环境物体、静态道具等,性能较好
Skinned Mesh Renderer 渲染带骨骼动画的网格,网格的顶点受骨骼影响,可以发生形变 通常用于角色、怪物等有动画需求的物体,性能开销较大(每帧需要 CPU 或 GPU 进行蒙皮计算)

12. Unity 中 Material(材质)的作用是什么?

答案:

Material 是 Shader 的一个实例。它将 Shader 和具体的纹理、颜色、数值等属性结合起来。简单来说,Shader 是算法,Material 是参数。一个 Shader 可以创建多个不同的 Material,实现不同的视觉效果。


13. Unity 中 Shader(着色器)的作用是什么?

答案:

Shader 是一段运行在 GPU 上的程序,它定义了物体如何被渲染。它控制着顶点变换(Vertex Shader)和像素颜色计算(Fragment Shader),决定了物体的光照模型、纹理映射、阴影、透明度等视觉效果。


14. Unity 生命周期函数中在整个生命周期中可能会被反复执行的有哪些?(至少说出 5 个)

答案:

  1. Update -- 每帧执行一次。
  2. LateUpdate -- 每帧执行一次,在所有 Update 之后。
  3. FixedUpdate -- 根据物理时间步长执行,可多次执行。
  4. OnGUI -- 每帧可能执行多次(用于渲染 GUI)。
  5. OnAnimatorIK -- 每帧执行,用于 IK 处理。
  6. OnAnimatorMove -- 每帧执行,用于自定义动画移动。
  7. OnWillRenderObject -- 当对象即将被渲染时调用(每个相机可能触发一次)。
  8. OnBecameVisible / OnBecameInvisible -- 当对象对任何相机可见/不可见时触发。
  9. OnCollisionStay / OnTriggerStay -- 持续碰撞时每帧调用。

15. Unity 中如果想要改变物理系统中默认重力的方向或大小,应该在哪里修改?

答案:

在 Edit → Project Settings → Physics(或 Physics 2D)中,修改 Gravity 向量的值。默认是 (0, -9.81, 0)。可以修改为任意方向,比如横版游戏可能需要 X 轴方向的重力:(0, -9.81, 0) → (-9.81, 0, 0) 等。

代码修改:

csharp 复制代码
Physics.gravity = new Vector3(0, -9.81f, 0);

16. Transform 中的本地坐标系的方向 转换为相对世界坐标系的方向 的两个 API TransformVector () 和 TransformDirection () 有什么区别?

答案:

API 说明 是否考虑缩放
TransformDirection 将一个方向向量从本地坐标系转换到世界坐标系。只考虑旋转,不考虑缩放。适合用于转换方向,如向前、向上等。 否
TransformVector 将一个向量(具有大小和方向)从本地坐标系转换到世界坐标系。会考虑物体的缩放,即向量的长度会根据物体的缩放而改变。适合用于转换位置偏移、速度等受缩放影响的向量。 是

示例:

csharp 复制代码
// 假设物体旋转 90 度,缩放为 (2,2,2)
Vector3 localForward = Vector3.forward;

Vector3 worldDir = transform.TransformDirection(localForward);
// 结果:世界方向 (1,0,0)(旋转后),长度仍为 1

Vector3 worldVec = transform.TransformVector(localForward);
// 结果:世界方向 (2,0,0),长度变为 2(缩放生效)

17. 该 Unity 中的报错是什么意思?如何定位该问题?我们一般如何排查解决该问题?(至少说出 2 种方案)(报错信息:NullReferenceException: Object reference not set to an instance of an object Test.Start () (at Assets/Scripts/Test.cs:11))

答案:

含义 :在 Test.cs 脚本的第 11 行,Start() 方法中,尝试访问了一个为 null 的对象引用。

定位 :报错信息明确指向文件 Test.cs 和行号 11。

排查与解决:

  1. 打开 Test.cs,找到第 11 行,检查该行访问的变量或组件引用。

  2. 使用 Debug.Log 或断点:在该行前打印相关变量,确认是否为 null。

  3. 检查 GetComponent 是否成功 :如果使用了 GetComponent,确保该组件确实存在于游戏对象上。

  4. 检查 Inspector 赋值 :如果是通过 public 变量在 Inspector 中拖拽赋值的,检查是否忘记赋值。

  5. 添加判空保护 :

    csharp 复制代码
    if (reference != null) { reference.DoSomething(); }
  6. 检查对象是否被销毁 :可能引用的对象被 Destroy 了,但引用未置空。

  7. 使用 RequireComponent :在类上添加 [RequireComponent(typeof(ComponentType))],强制依赖组件存在。


18. IK(逆运动学)可以帮助我们完成什么功能?

答案:

IK(Inverse Kinematics,逆运动学)是一种根据末端执行器的位置,反向计算出骨骼链中各个关节旋转角度的算法。在 Unity 中,IK 常用于:

  • 角色脚部 IK:让角色的脚能够贴合地面,适应不平整的地形。
  • 角色手部 IK:让角色的手能够抓住物体或瞄准目标。
  • 头部 IK:让角色的头部跟随目标(如摄像机或 NPC)转动。
  • 武器 IK:让角色的手准确握住武器的特定位置。
  • 四肢 IK:如爬行、攀爬时四肢位置调整。

Unity 中通过 OnAnimatorIK 回调函数和 Animator.SetIKPositionWeight、SetIKRotationWeight 等方法实现 IK。


19. Unity 中为 Sprite 前后关系排序的常用方式有哪些?(至少说出 3 种)

答案:

  1. Sorting Layer 和 Order in Layer :在 Sprite Renderer 组件中,设置 Sorting Layer 和 Order in Layer。Unity 先按 Sorting Layer 的优先级排序,再按 Order in Layer 的值排序,值越大越靠前(显示在上层)。
  2. Transform 的 Z 轴位置:在 3D 空间中,可以通过修改 Sprite 的 Z 轴位置(即深度)来控制渲染顺序。相机距离近的物体覆盖远的物体(需要相机设置为透视模式且 Z 轴有差异)。
  3. 透明排序模式 :对于透明物体,Unity 默认是从后往前渲染。可以通过设置 Material 的渲染队列(Render Queue)来控制。例如,RenderQueue 值越小越先渲染。
  4. 脚本动态修改 :通过代码动态修改 SpriteRenderer.sortingOrder 来实现动态排序(如角色在不同场景层级变化时)。

20. Unity 在发布 Android 平台项目时,在加载Application.streamingAssetsPath中文件时我们应该注意什么?

答案:

Application.streamingAssetsPath 在 Android 平台上指向的是 APK 包内的 assets 目录。这个目录是 只读 的,并且 不能 使用标准的 System.IO.File 类直接访问。必须使用 UnityWebRequest 来读取其中的文件。

示例:

csharp 复制代码
IEnumerator LoadFileFromStreamingAssets()
{
    string path = Path.Combine(Application.streamingAssetsPath, "myfile.txt");
    using (UnityWebRequest request = UnityWebRequest.Get(path))
    {
        yield return request.SendWebRequest();
        if (request.result == UnityWebRequest.Result.Success)
        {
            string content = request.downloadHandler.text;
            // 处理内容
        }
    }
}

注意: 对于 Android 平台,Application.streamingAssetsPath 返回的路径是 jar:file://... 格式,必须通过 UnityWebRequest 访问。


21. Unity 生命周期函数中的 OnEnable 和 Start,我们在使用时应该如何选择?可以举例说明。

答案:

函数 调用时机 适用场景
Start 在脚本实例第一次启用且在其所在的 GameObject 处于激活状态 时,在 Update 之前调用一次。 只需要执行一次的初始化,且不依赖于脚本的启用/禁用状态(如获取组件引用、初始化数据)。
OnEnable 在脚本实例每次被启用时都会调用(包括 GameObject 从非激活变为激活时)。 需要在脚本每次启用时都重新初始化状态,例如从对象池中取出时重置数据、重新订阅事件等。

举例:

  • 一个 UI 面板,每次打开时需要根据最新数据刷新显示内容。那么刷新逻辑应放在 OnEnable 中,因为面板可能多次关闭和打开。
  • 一个角色控制器,只需要在游戏开始时获取一次 Rigidbody 组件引用,那么放在 Start 中即可。

22. 如果在 Unity 当中制作 FPS 游戏,如何模拟枪械开枪时的后坐力?

答案:

后坐力通常表现为准星(或相机视角)的随机上跳和左右偏移。

  1. 旋转相机:每次开枪时,根据枪械的后坐力参数,随机增加相机的 Pitch(上下)和 Yaw(左右)角度。

    csharp 复制代码
    float pitchRecoil = Random.Range(-recoilXMin, recoilXMax);
    float yawRecoil = Random.Range(-recoilYMin, recoilYMax);
    transform.Rotate(pitchRecoil, yawRecoil, 0);
  2. 动画:播放枪械模型的特定后坐力动画,使枪口上跳,同时相机角度同步变化。

  3. 后坐力恢复:在后坐力动画结束后,让相机缓慢恢复到原位置,或者通过一个恢复速度(Recoil Recovery)持续衰减后坐力角度。

    csharp 复制代码
    currentRecoil = Mathf.Lerp(currentRecoil, 0, recoverySpeed * Time.deltaTime);
  4. 随机性与模式:可以设计固定的后坐力模式(如 CS:GO 的弹道模式),或完全随机的弹道散布。

  5. 同步:相机旋转和枪械动画应同时进行,增强视觉反馈。


23. Unity 中 Application.persistentDataPath 和 Application.streamingAssetsPath 的区别是什么?

答案:

路径 位置 读写性 用途
persistentDataPath 应用沙盒内的可读写目录(Android: /data/data/包名/files;iOS: Documents;PC: 用户数据目录) 可读写 存放游戏存档、下载的配置、日志文件等需要持久化的数据。应用卸载时会被删除。
streamingAssetsPath 应用安装包内的只读目录(Android: APK 内的 assets;iOS: 应用内的 Data/Raw;PC: StreamingAssets 文件夹) 只读 存放不需要压缩、需要原样访问的资源,如视频、配置文件、数据库文件。Android 上需用 UnityWebRequest 访问。

注意事项:

  • Android 上 streamingAssetsPath 返回的是 jar:file:// 路径,不能直接用 File 类读取,必须用 UnityWebRequest。
  • persistentDataPath 在不同平台路径不同,但都可读写,适合存储用户生成的数据。

24. Unity 中 Input 系统的新旧版本有什么区别?何时使用新版 Input System?

答案:

旧版 Input Manager(默认):

  • 基于 Input.GetAxis、Input.GetKey 等静态方法。
  • 配置通过 Project Settings → Input Manager,最多支持 20 个轴。
  • 优点:简单、上手快。
  • 缺点:不支持多玩家设备分离、不支持自定义设备、不支持触摸屏高级手势、硬编码配置。

新版 Input System(Package):

  • 基于事件驱动或 PlayerInput 组件,支持多玩家设备映射。
  • 支持各种设备(鼠标、键盘、手柄、触摸屏、VR 控制器等)统一管理。
  • 通过 .inputactions 资产配置输入映射,支持交互(Hold、Tap、Multi-tap)和处理器(SmoothDelta、NormalizeVector2)。
  • 优点:灵活、可扩展、支持运行时重映射、性能更好(基于事件,无每帧查询)。
  • 缺点:学习曲线较陡,需要安装 Package。

使用场景:

  • 旧项目快速开发、简单输入需求 → 旧版 Input Manager。
  • 需要支持多玩家、自定义设备、高级手势、运行时重映射 → 新版 Input System。
  • Unity 官方推荐新项目使用新版 Input System。

切换方式 :在 Project Settings → Player → Active Input Handling 中可选择旧版 Input Manager、新版 Input System,或两者同时启用。


25. Unity 中 Animation 组件和 Animator 组件的主要区别是什么?

答案:

组件 用途 特点
Animation 播放单一的动画剪辑(Legacy 动画系统) 简单,直接指定动画剪辑播放,支持淡入淡出和混合。性能较好,但功能有限,不支持状态机、参数化过渡等。主要用于老项目或极简动画。
Animator 通过 Animator Controller 管理动画状态机(Mecanim 系统) 支持状态机、过渡条件、动画层、IK、重定向(Humanoid 动画)。功能强大,适合复杂角色动画。可处理多个动画之间的平滑过渡和逻辑控制。

选择建议:

  • 简单动画(如 UI 动画、单个动画物体)可用 Animation。
  • 角色动画、复杂状态切换、IK 需求等必须用 Animator。
  • Unity 现在推荐使用 Animator,Animation 组件已逐渐被取代。

26. Unity 中 Camera 的 Clear Flags 和 Culling Mask 分别有什么作用?

答案:

  • Clear Flags:决定相机渲染前如何清除之前的颜色和深度缓冲区。

    • Skybox:用天空盒清除,如果无天空盒则用背景色。
    • Solid Color:用背景色清除。
    • Depth Only:只清除深度,保留颜色(用于叠加多个相机渲染)。
    • Don't Clear:不清除任何缓冲区,可能导致前一帧残留。
  • Culling Mask:指定相机渲染哪些层(Layers)。只有勾选的层的物体才会被该相机渲染,常用于多相机分工(如 UI 相机只渲染 UI 层,主相机不渲染 UI 层)。

应用场景:

  • 用多个相机实现小地图:主相机 Culling Mask 排除小地图层,小地图相机只渲染小地图层。
  • 叠加 UI 相机:主相机 Clear Flags 设为 Depth Only,UI 相机设为 Solid Color 并设置更高深度,实现 UI 始终在最前。

27. Unity 中 Physics.Raycast 和 Physics.SphereCast 的区别及使用场景是什么?

答案:

方法 描述 适用场景
Raycast 从起点沿方向发射一条射线,检测与碰撞体的交点。 射击检测、点击选择、视线判断(精确线状检测)。
SphereCast 从起点沿方向发射一个球体(半径>0),返回球体移动过程中第一个碰撞到的物体。 检测一定半径内的物体,如角色脚下的地面检测(替代射线更稳定)、子弹体积碰撞、连续移动检测(避免快速物体穿过薄墙)。

区别:

  • Raycast 是无限细的线,容易漏过小物体或快速运动物体(需配合连续碰撞检测)。
  • SphereCast 有体积,对快速运动物体更友好,但开销稍大。

示例:

csharp 复制代码
// 射线检测
RaycastHit hit;
if (Physics.Raycast(origin, direction, out hit, maxDistance))
{ }

// 球形检测
if (Physics.SphereCast(origin, radius, direction, out hit, maxDistance))
{ }

28. 点乘叉乘

在游戏开发中, 点乘(Dot Product)和叉乘(Cross Product) 是非常常用的向量运算。

最简单的记忆方式:

  • 点乘:得到一个数,主要判断方向关系。
  • 叉乘:得到一个向量,主要判断方向关系 + 垂直方向。

在 Unity 中,它们分别对应:

csharp 复制代码
Vector3.Dot(a, b);
Vector3.Cross(a, b);

面试口述总结

点乘和叉乘都是游戏开发中非常常用的向量运算。

点乘的结果是一个标量,它的几何意义是 |A||B|cosθ,所以主要用于判断两个向量的夹角和方向关系,比如判断敌人在角色前方还是后方、视野检测等。

叉乘的结果是一个向量,它的方向同时垂直于两个输入向量,长度是 |A||B|sinθ,所以常用于求三角形法线、判断两个方向的左右关系以及计算旋转方向。

简单记就是:点乘看 cos,判断夹角;叉乘看 sin,求垂直方向。


29. 四元数(Quaternion)和欧拉角有什么区别?什么是万向锁(Gimbal Lock)?

答案:

旋转的三种常用表示:欧拉角、四元数、旋转矩阵。

表示 优点 缺点
欧拉角 直观、易理解(绕 X/Y/Z 各转多少度) 存在万向锁;表示不唯一;插值不自然
四元数 无万向锁;插值平滑(Slerp);内存小、运算快 不直观,难以直接读数
旋转矩阵 适合组合变换、直接作用于向量 9 个浮点内存大;插值困难;易因数值误差失去正交性

万向锁:欧拉角按固定顺序绕三个轴旋转,当中间某个轴旋转到 ±90° 时,会导致两个旋转轴重合,丢失一个旋转自由度。例如 pitch=90° 时,yaw 和 roll 的旋转效果合并成一个,无法唯一表示某些姿态,插值路径也会出现异常跳动。

Unity 中的处理:

  • Inspector 面板显示的是欧拉角,但引擎内部存储的是四元数。
  • 需要做平滑旋转插值时用 Quaternion.Lerp/Slerp,不要对欧拉角三个分量分别 Lerp。
  • transform.rotation(Quaternion)和 transform.eulerAngles(欧拉角)可互相转换,但直接改 eulerAngles 的三个分量要小心,因为欧拉角不唯一。

30. Lerp、Slerp、Nlerp 的区别是什么?

答案:

插值 含义 特点 适用
Lerp 线性插值 两点间走直线,速度快、开销低,但旋转时角速度不均匀(中间快、两端慢),路径不沿球面 位置、颜色、数值
Slerp 球面线性插值 沿大圆弧匀速旋转,角度过渡最平滑自然,但涉及三角函数、开销稍大 需要精确平滑的旋转插值
Nlerp 归一化线性插值 先做 Lerp 再归一化,速度比 Slerp 快,角度不大时结果近似 Slerp 大多数旋转插值场景(性能优先)

关键点:

  • 旋转插值不要 直接对欧拉角三分量做 Mathf.Lerp,会得到不平滑、甚至诡异的路径。
  • 通常做法:位置用 Vector3.Lerp,旋转用 Quaternion.Slerp(精确)或 Quaternion.Lerp(内部其实也是归一化后的近似,多数情况够用)。

31. FixedUpdate 和物理时间步长(Fixed Timestep)的关系?为什么物理模拟要放在 FixedUpdate?

答案:

  • Update:每渲染一帧调用一次,频率随帧率波动。
  • FixedUpdate :按固定的时间步长(默认 Time.fixedDeltaTime = 0.02s,即 50 次/秒)调用,与帧率无关。

为什么物理要放 FixedUpdate:

物理引擎需要固定步长 才能保证模拟的确定性、稳定性 和跨设备结果一致。如果物理模拟随帧率变化,帧率高低会导致受力、碰撞、刚体运动结果不一致,甚至出现抖动或穿透。

执行细节:

  • 一帧内 FixedUpdate 可能被调用 0 次、1 次或多次:当一帧耗时大于 fixedDeltaTime 时,会补多次;小于时可能跳过。
  • 物理相关代码(施加力、修改 velocity、操作 Rigidbody)应放在 FixedUpdate;玩家输入检测放 Update;相机跟随放 LateUpdate(在所有 Update 之后,避免抖动)。

调整:

  • 在 Edit → Project Settings → Time 中修改 Fixed Timestep,或用代码 Time.fixedDeltaTime。

32. CharacterController 和 Rigidbody 有什么区别?分别在什么场景使用?

答案:

对比 CharacterController Rigidbody
本质 自带胶囊碰撞体,但不受刚体物理模拟影响(无质量、不自动受重力/反作用力) 受物理引擎完整模拟(重力、碰撞反作用、力、扭矩)
移动方式 手动调用 Move()/SimpleMove() 控制位移 通过施加力(AddForce)、设置 velocity 等
碰撞响应 只做碰撞阻挡,不被其它物体推飞 会与其它刚体产生真实的碰撞反弹、推动
稳定性 可控、稳定,适合精确控制角色 可能被意外撞飞、需要约束或调参数
适用 玩家角色、第一/第三人称控制器、NPC 掉落物、车辆、布娃娃、需要真实物理的物体

选择:

  • 需要稳定可控的角色移动 用 CharacterController(不会因为踩到小石子被弹飞)。
  • 需要真实物理反馈 (被撞飞、重力坠落、堆叠)用 Rigidbody。
  • 也可以用 Rigidbody + 冻结旋转/限制轴来做角色,但通常比 CharacterController 更难调稳。

33. 什么是连续碰撞检测(Continuous Collision Detection,CCD)?什么时候需要开启?

答案:

离散检测(Discrete)的问题:

默认情况下,物理引擎在每个物理步按物体当前时刻的位置 做碰撞检测。对于高速运动的物体(如子弹),一个时间步内可能从薄墙的一侧直接移动到另一侧,导致隧穿(Tunneling),即检测不到碰撞直接穿过去。

连续碰撞检测(CCD):

CCD 会扫描物体在两个物理步之间的运动路径,检测运动过程中是否与其它碰撞体发生碰撞,从而避免隧穿。代价是更高的计算开销。

Rigidbody 的 Collision Detection 选项:

  • Discrete:默认,逐帧按位置检测。
  • Continuous:适合高速移动、可能撞到静态物体(墙)的物体。
  • Continuous Dynamic:适合两个高速动态物体互相碰撞(如两颗子弹相撞)。
  • Continuous Speculative:代价更低的近似 CCD,适合一般高速物体。

使用建议:

  • 子弹、快速投掷物、高速移动的角色 → 开启 CCD。
  • 普通物体不要盲目开启 CCD,会无谓增加物理开销;配合降低 Fixed Timestep、用 SphereCast 等也能缓解隧穿。

34. 物理层(Layer)和碰撞矩阵如何优化性能?

答案:

Layer 与碰撞矩阵:

  • 给物体分配 Layer(如 Player、Enemy、Ground、Bullet),在 Project Settings → Physics 的碰撞矩阵里配置哪些层之间需要做碰撞检测。
  • 取消不需要的层对(如子弹与子弹、敌人与敌人、道具与道具),物理引擎就不会检测这些层之间的碰撞,显著减少碰撞检测计算量。

其它物理优化手段:

  1. 简化碰撞体:用 Box/Sphere/Capsule 等 primitive collider 替代 MeshCollider(除非必须用,MeshCollider 通常只给静态物体)。
  2. 减少刚体数量 :静态物体标 Static 或只挂 Collider 不挂 Rigidbody。
  3. 触发器优先 :不需要碰撞反作用时用 Trigger(IsTrigger)替代实心碰撞。
  4. 控制 Fixed Timestep:适当降低物理更新频率(如 0.02s → 0.03s)可减少物理开销,但会降低精度。
  5. 避免高频修改碰撞体:运行时频繁缩放/移动带 Collider 的物体会触发物理重新计算。

二、进阶篇

1. Unity 中 SerializeReference(序列化引用)特性的用途是什么?

答案:

[SerializeReference] 特性允许在 Inspector 窗口中序列化一个抽象类或接口的引用,并且能够保留实际派生类(子类)的具体类型信息。这对于实现多态和灵活的架构非常有用。

传统序列化的问题:

csharp 复制代码
[System.Serializable]
public class Weapon
{
    public string name;
}

[System.Serializable]
public class Sword : Weapon { }
[System.Serializable]
public class Bow : Weapon { }

public class Character : MonoBehaviour
{
    public Weapon weapon;  // 在 Inspector 中只能选择 Weapon 的实例,无法选择 Sword 或 Bow
}

使用 [SerializeReference] 后:

csharp 复制代码
using System;
using UnityEngine;

[System.Serializable]
public class Weapon
{
    public string name;
}

[System.Serializable]
public class Sword : Weapon { }
[System.Serializable]
public class Bow : Weapon { }

public class Character : MonoBehaviour
{
    [SerializeReference]
    public Weapon weapon;  // 在 Inspector 中可以赋值 Sword 或 Bow,并且能编辑它们特有的字段
}

特点:

  • 支持多态:可以存储任何继承自 Weapon 的类型。
  • 支持接口:可以存储实现了某个接口的任意对象。
  • 序列化时不会因为类型信息丢失而变成基类实例。
  • 在 Inspector 中会显示一个下拉菜单,选择具体的派生类。

注意事项:

  • 被引用的类必须用 [System.Serializable] 标记。
  • 该特性是 Unity 2019.3 引入的,需要较新版本。
  • 与 [SerializeField] 不同,它不要求字段是 Unity 引擎内置的可序列化类型,而是直接序列化对象的完整状态。

2. Unity 中生命周期函数被调用的底层原理是什么?

答案:

Unity 引擎的核心是 C++ 编写的游戏循环(Game Loop)。每一帧,引擎会:

  1. 从 C++ 端向所有 C# 脚本的实例发送消息,调用它们特定的生命周期函数。
  2. 引擎内部维护了不同的列表(如 Awake 列表、Update 列表),分别存储实现了对应方法的脚本实例。
  3. 在帧循环的相应阶段(如物理更新阶段、渲染前阶段、渲染后阶段),引擎会遍历这些列表,通过内部调用(InternalCall)从 C++ 调用 C# 的方法。
  4. Update 和 FixedUpdate 的调用频率不同:Update 由帧率决定,FixedUpdate 由物理时钟控制,与帧率无关。

关键点:

  • 脚本实例是否在列表中取决于其类型是否定义了相应的方法。若未定义,则不会添加到列表,从而没有调用开销。
  • 在脚本加载阶段,由引擎(native 侧,mono / IL2CPP 元数据)扫描并缓存实现的方法信息,再据此构建调用列表(并非使用 C# 的 System.Reflection 反射)。

3. 在 Android 平台上运行的项目出现问题,我们有几种调试的方式?(至少说出 3 种方案,不使用第三方插件)

答案:

  1. Unity Profiler 连接真机 :在 Build Settings 中勾选 Development Build 和 Autoconnect Profiler,打包后运行,在编辑器中连接设备,实时分析 CPU、内存、渲染等数据。
  2. Logcat (Android Studio) :通过 adb logcat 查看 Unity 输出的 Debug.Log 以及 Android 系统的日志,可过滤 Unity 标签。
  3. Device Explorer (Android Studio):查看应用沙盒目录下的文件,如崩溃日志、输出文件等。
  4. Frame Debugger:连接真机,单帧分析渲染过程,查看 DrawCall 和渲染状态。
  5. Build with Script Debugging :在 Build Settings 中勾选 Development Build 和 Script Debugging,然后连接 IDE(如 Visual Studio 或 Rider)进行断点调试。
  6. 使用 Application.logMessageReceived 事件:将日志写入文件或通过网络发送到调试服务器。

4. Unity 当中存在多线程时,继承 MonoBehaviour 的脚本是否有必要对其中内容加锁?为什么?

答案:

有必要 。因为 MonoBehaviour 的生命周期函数(如 Update、Start)都是在 主线程 上执行的。如果在其他线程(如 Job System、ThreadPool)中修改 MonoBehaviour 的字段或调用其方法,就可能导致 数据竞争 和 线程安全问题 。因此,当多线程需要访问共享数据时,必须使用锁(lock)或其他同步机制来保护这些数据。

最佳实践 :多线程只处理数据计算,然后将结果通过主线程的分发机制(如 MainThreadDispatcher)传递给 MonoBehaviour 进行应用。Unity 的 Job System 也有安全的 API 可以安全地与主线程交互。


5. 我们在 Unity 中进行一些复杂逻辑处理时,比如网路通讯、寻路算法,往往会开启多线程进行处理。我们如何保证数据能够和 Unity 主线程进行正常交互?(请至少说出 1 种方式)

答案:

1. 使用队列(推荐) :创建一个线程安全的队列(如 ConcurrentQueue<T>)。在工作线程中将需要主线程执行的操作(如实例化对象、修改组件属性)封装成委托或数据包,放入队列中。在主线程的 Update 中,不断从队列中取出并执行这些操作。

csharp 复制代码
private ConcurrentQueue<Action> _mainThreadActions = new ConcurrentQueue<Action>();

void Update()
{
    while (_mainThreadActions.TryDequeue(out var action))
        action?.Invoke();
}

void WorkerThread()
{
    // 耗时计算...
    _mainThreadActions.Enqueue(() => {
        // 在主线程执行,如更新 UI、实例化对象
    });
}
  1. 使用 UnityMainThreadDispatcher :这是一个常见的开源组件,它利用 SynchronizationContext 或自定义的 Update 调度机制,允许在任何线程中调用 Dispatcher.Invoke(() => { ... }) 将代码发送到主线程执行。

  2. 使用 Task 和 UniTask :通过 await 将异步任务与主线程同步,UniTask 提供了更好的 Unity 集成,可以直接在协程中使用,并自动回到主线程。

  3. 使用 Coroutine :虽然 Coroutine 本身在主线程上运行,但可以通过 yield return 等待一个 Task 完成,从而实现异步结果的获取。


6. Unity 中协程(Coroutine)的原理是什么?使用时有哪些注意事项?

答案:

原理:

协程是一种特殊的迭代器函数,返回类型为 IEnumerator。Unity 在每帧的特定时机(通常是在 Update 之后)遍历所有启动的协程,调用 MoveNext() 推进迭代器执行。当遇到 yield return 时,协程暂停,并在满足条件后(如经过 WaitForSeconds 指定的时间、等待下一帧等)继续执行。协程不是多线程,它仍然运行在主线程上,但允许将任务分散到多帧执行,避免卡顿。本质上,包含 yield return 的方法会被编译器转换为一个迭代器状态机,MoveNext() 负责保存并推进执行状态。

注意事项:

  • 协程依赖于 MonoBehaviour 对象,如果对象被销毁或禁用,协程也会停止(除非使用 StopCoroutine 或对象销毁)。
  • 使用 yield return null 等待下一帧;yield return new WaitForSeconds(1) 会产生 GC 分配,应缓存 WaitForSeconds 实例。
  • 协程中不能使用 try-catch-finally 直接包围 yield 语句(但可以在外围使用)。
  • 大量协程同时运行会增加开销,应避免创建过多短生命周期协程。
  • 在对象池场景中,需在对象回收前停止协程,否则协程可能继续操作已回池的对象。
  • 协程的启动通过 StartCoroutine,停止可通过 StopCoroutine、StopAllCoroutines 或直接销毁对象。

7. Unity 中 ScriptableObject 的用途和优势是什么?

答案:

用途:

ScriptableObject 是一种可以在 Unity 编辑器中创建和保存数据的独立资源,常用于:

  • 配置数据(如角色属性、武器数值、关卡配置)
  • 游戏事件系统(事件通道)
  • 数据容器(如背包物品库)
  • 编辑器工具的数据存储

优势:

  • 数据与逻辑分离:配置数据作为资源,可以在不修改代码的情况下调整数值,便于策划使用。
  • 内存节省:多个 GameObject 可以共享同一个 ScriptableObject 实例,避免重复存储相同数据。
  • 运行时可修改 :可在运行时动态修改 ScriptableObject 的值,但该修改仅存在于内存中;EditorUtility.SetDirty 是编辑器 API,用于将修改标记为待保存(仅编辑器可用),打包后的 Player 无法将 SO 写回磁盘。
  • 热更新友好:数据可打包在 AssetBundle 中实现热更新。
  • 编辑器友好 :在 Inspector 中直接编辑,支持继承和多态(配合 [SerializeReference])。

示例:

csharp 复制代码
[CreateAssetMenu(fileName = "NewWeapon", menuName = "Game/Weapon Data")]
public class WeaponData : ScriptableObject
{
    public string weaponName;
    public int damage;
    public float fireRate;
}

8. Unity 中 AssetPostprocessor 是什么?有哪些应用场景?

答案:

AssetPostprocessor 是一个可以继承的类,允许在资源导入到 Unity 时自动执行自定义处理。通过重写其特定方法(如 OnPreprocessTexture、OnPostprocessModel 等),可以修改资源的导入设置或生成额外数据。

常用场景:

  • 批量设置纹理格式、最大尺寸、压缩类型(如根据纹理命名自动设置)。
  • 模型导入后自动生成 LOD 或修改材质。
  • 自动生成资源依赖关系报告。
  • 根据导入的音频文件自动修改加载类型。
  • 处理自定义文件格式(如 .json 导入后自动生成 ScriptableObject)。

示例:

csharp 复制代码
public class TexturePostprocessor : AssetPostprocessor
{
    void OnPreprocessTexture()
    {
        TextureImporter importer = (TextureImporter)assetImporter;
        importer.textureType = TextureImporterType.Sprite;
        importer.maxTextureSize = 1024;
        if (assetPath.Contains("UI"))
            importer.textureCompression = TextureImporterCompression.Compressed;
    }
}

9. Unity 中 EditorWindow 的用途和开发流程是什么?

答案:

EditorWindow 是 Unity 编辑器扩展的基础类,用于创建自定义编辑器窗口,增强开发工具链。

用途:

  • 数据编辑器(如关卡编辑器、技能编辑器)
  • 批量处理工具(如资源重命名、检查缺失引用)
  • 性能监控面板(自定义 Profiler 视图)
  • 项目管理工具(如版本发布助手)

开发流程:

  1. 创建脚本继承 EditorWindow。
  2. 使用 [MenuItem("Tools/My Window")] 添加菜单项,在静态方法中调用 EditorWindow.GetWindow<T>() 打开窗口。
  3. 重写 OnGUI() 绘制窗口内容,使用 EditorGUILayout 和 GUI 绘制控件。
  4. 利用 Editor 命名空间下的 API 访问和修改资源、场景对象。

示例:

csharp 复制代码
using UnityEditor;
using UnityEngine;

public class MyWindow : EditorWindow
{
    [MenuItem("Tools/My Window")]
    public static void ShowWindow()
    {
        GetWindow<MyWindow>("My Window");
    }

    private string text = "";

    void OnGUI()
    {
        GUILayout.Label("Hello World");
        text = EditorGUILayout.TextField("Input", text);
        if (GUILayout.Button("Print"))
        {
            Debug.Log(text);
        }
    }
}

10. A* 算法是什么?实现原理是什么

A* 是游戏最常用的网格寻路算法 ,从起点找一条最优路径到达终点;是 Dijkstra + 贪心 BFS 的结合。

核心公式:F = G + H

  • G :从起点走到当前格子的实际消耗(真实代价)
  • H :从当前格子预估走到终点的估算代价(启发值)
  • F:综合优先级,每次选 F 最小的格子去扩展

11. Dictionary 底层原理,如何解决哈希冲突

csharp 复制代码
private struct Entry
{
    public int hashCode;   // key的哈希码
    public int next;       // 同一个桶内,下一个entry的下标,-1代表链表结尾
    public TKey key;
    public TValue value;
}

Dictionary 底层有两个数组,_buckets桶数组和_entries实体数组。

根据 key 的 GetHashCode 取模得到桶下标;发生哈希冲突时采用拉链法,通过 Entry 里面的 next 字段把冲突元素串成单向链表。查找时先定位桶,再遍历链表用 Equals 比对 key。


12. C#中的迭代器是什么?在使用时需要注意哪些内容

【游戏开发】C# 中的迭代器

迭代器是一种可以逐个返回序列中元素的机制,不用一次性把全部数据加载到内存,按需生成元素。

核心两个接口:

  1. IEnumerable:可被枚举,代表可以产出一个序列 ;有一个方法 GetEnumerator() 返回枚举器。
  2. IEnumerator:枚举器,代表遍历的状态 ;有 MoveNext()、Current、Reset()。
csharp 复制代码
public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

public interface IEnumerator
{
    bool MoveNext();   // 移动到下一个元素,有元素返回true,没有返回false
    object Current { get; } // 获取当前元素
    void Reset();
}

面试口述总结

C# 的迭代器本质上是一种按需产生数据、逐步遍历的机制,最典型的实现就是 yield return。

使用 yield 的方法会被编译器转换成一个状态机,通过 IEnumerator 的 MoveNext() 保存和推进当前执行状态。

IEnumerable 表示可遍历的数据源,IEnumerator 负责具体的遍历过程。使用时需要注意延迟执行、重复遍历可能导致逻辑重复执行,以及 Unity 高频代码中的 GC 和性能问题。

另外 Unity 协程本质上也是建立在 IEnumerator 和 yield 机制之上的,但 IEnumerator 本身并不等于协程。


13. 讲一下ECS是什么,怎样使用以及优缺点

核心思想:实体 (Entity) 只是 ID;组件 (Component) 只有纯数据;系统 (System) 执行业务逻辑 。

传统 OOP:GameObject 挂载 MonoBehaviour,数据和逻辑绑在一起。

ECS:数据与逻辑完全分离。

ECS 优点

  1. CPU 缓存友好,性能极高
    同 Archetype 组件连续内存,减少 CPU Cache Miss;适合大量实体(上千上万个怪物、子弹)。
  2. 天然适合多线程(Job System + Burst)
    数据分离,没有 Mono、没有堆对象;Burst 把 C# Job 编译成高度优化机器码;多线程安全,不容易写出线程冲突。
  3. GC 几乎为 0
    组件是 struct 值类型,存于 Chunk,很少堆分配,几乎没有 GC,大批量对象不会 GC 卡顿。
  4. 数据驱动
    运行时可以动态增删组件,改变 Entity 行为;不需要挂脚本。给 Entity 增加EnemyTag就变成敌人,移除就失去敌人行为。
  5. 适合大世界、大量实体场景:弹幕、海量 NPC、群像。

ECS 缺点(面试重点,不能只说好)

  1. 学习曲线陡峭,思维要从 OOP 切换到数据驱动
    不能写面向对象逻辑;组件只能是纯数据;习惯 Mono 的开发者上手成本高。
  2. 和传统 UGUI、GameObject 交互麻烦
    ECS Entity 不是 GameObject。想要渲染物体,要依靠RenderMesh组件,或者转换 Entity‑GameObject 桥梁,调试不直观。
  3. 调试困难
    没有 Mono 那样直接 Inspector 看脚本;看数据要使用 Entities Debugger 工具。
  4. 开发效率低,代码量变多
    简单逻辑也要拆分成组件 + System;小型游戏、角色很少的游戏,用 ECS 收益很低,反而增加工作量。
  5. 版本稳定性:Unity Entities 包以前是预览包,API 有过变动。
  6. 实体增删组件会改变 Archetype,会发生数据 Chunk 拷贝迁移,频繁 AddComponent/RemoveComponent 有开销。

面试口述总结

ECS 分为 Entity、Component、System。Entity 只是 ID;Component 是纯数据 struct;System 处理逻辑。

相同组件组合 (Archetype) 的实体组件数据连续存放在 Chunk 内存块,CPU 缓存命中率高;配合 Job System 多线程和 Burst 编译器,处理大量实体性能很强,几乎无 GC。

  • 优点:缓存友好、支持多线程、低 GC,适合海量游戏对象。
  • 缺点:思维模式需要从 OOP 转到数据驱动,上手难度大;与 GameObject 体系交互繁琐,调试不便;频繁增删组件会触发 Chunk 迁移拷贝;小项目收益不大。

14. 游戏中的新手引导一般怎样设计,有哪些需要注意的地方

新手引导一般会设计成一个独立的引导系统,而不是把引导逻辑散落在各个 UI 和玩法代码里面。

核心上通常分成三部分:

  • 引导配置:定义每一步引导做什么、显示什么、需要玩家操作什么。
  • 引导流程控制器:负责步骤切换、条件判断、暂停/恢复、完成状态。
  • 表现层:负责高亮区域、箭头、手指动画、文字提示、遮罩等。

这样可以把"游戏业务逻辑"和"引导逻辑"解耦,后续修改新手流程不会大量修改业务代码。

面试口述总结

新手引导首先是一个流程系统,而不只是 UI。

实际开发中我会把它拆成配置、流程控制和表现三层,引导步骤通过 ScriptableObject 等方式配置,TutorialManager 负责流程和状态机,具体业务通过事件通知引导系统完成条件。表现层负责高亮、遮罩、箭头和提示。

另外需要重点考虑引导进度持久化、场景切换、动态 UI、错误点击、跳过、弹窗打断以及版本兼容。产品设计上则是从强引导逐渐过渡到弱引导,避免让玩家感觉是在被强迫操作。


15. 游戏中的换装系统如何实现

游戏中的换装系统,本质上是:角色基础模型 + 装备数据 + 外观资源 + 装备槽位 + 换装逻辑 + 持久化存档

把装备和角色的不同身体部位、武器、饰品等进行映射,玩家更换装备后,系统根据装备数据找到对应资源并更新角色表现。

一个常见结构:

复制代码
                    换装系统
                       │
        ┌──────────────┼──────────────┐
        ↓              ↓              ↓
     装备数据        角色系统        资源系统
        │              │              │
   ItemConfig      Avatar/Model    Addressables
        │              │              │
        └──────────────┼──────────────┘
                       ↓
                  EquipmentManager
                       ↓
                更新角色外观

面试中最重要的是说明:不要把"换装"设计成大量业务代码直接操作 GameObject,而应该用数据驱动,根据装备配置控制角色各个装备槽位的资源。

面试口述总结

游戏中的换装系统一般采用数据驱动的方式设计,首先定义 Head、Body、Weapon、Accessory 等装备槽位,每件装备包含装备 ID、槽位、属性数据以及对应的外观资源。换装时由 EquipmentManager 根据装备数据找到对应的资源,然后更新角色表现。

服装类通常使用 SkinnedMeshRenderer 换装,核心是把装备的骨骼正确映射到角色 Skeleton;武器、翅膀、饰品这类物品一般直接挂载到对应 Bone 上。

属性和外观最好解耦,资源则通过 Addressables 或 AssetBundle 按需加载。除此之外,还要考虑身体遮挡导致的穿模、资源缓存、频繁换装的性能以及存档恢复等问题。


16. 游戏中的红点系统怎样设计

游戏里的红点系统,本质上是一个:"状态变化 → 条件判断 → 红点状态计算 → 红点向上聚合 → UI刷新" 的状态驱动系统。

红点系统一般是树形结构:

复制代码
Main
├── Mail
├── Quest
│   ├── DailyQuest
│   └── Achievement
├── Bag
│   ├── Equipment
│   └── Item
└── Activity
    ├── DailyReward
    └── SignIn

如果宝箱有一个可领取奖励:

复制代码
宝箱红点 = 1
      ↓
背包红点 = 1
      ↓
主界面红点 = 1

所以红点系统最核心的设计思想是:业务系统负责产生"状态",红点系统负责判断"是否应该显示红点",UI 只负责表现。

一般不要把红点逻辑全部写在各个 UI 里面。

面试口述总结

游戏中的红点系统,一般会设计成树形的状态管理系统。

每个功能对应一个红点节点,比如 Main、Bag、Quest、Activity,子节点的状态可以向父节点聚合。红点本身不直接写在 UI 中,而是根据业务条件通过 Checker 计算,比如未读邮件、可领取任务或者今天是否可以签到。

业务数据发生变化时,通过事件通知红点系统重新计算受影响的节点,然后向上局部更新,避免每帧轮询或者全量刷新。红点状态最好不仅支持 Bool,也支持 Count。

UI 只负责根据 RedPointNode 的状态显示或隐藏。

对于大型项目,还需要考虑初始化批量计算、服务器权威数据、动态活动条件以及红点消费后的状态更新。


17. Excel导出的表,有哪些方案可以优化

游戏里的 Excel 配置表,真正需要优化的通常不是 Excel 文件本身,而是:Excel → JSON/Lua → 客户端加载 → 内存占用 → 运行时访问

如果一张表导出 JSON、Lua 后非常大,最常见的优化方向是:

复制代码
Excel
 ↓
数据预处理
 ↓
去冗余 / 压缩 / 二进制化
 ↓
客户端数据结构优化
 ↓
按需加载
 ↓
运行时缓存

其中面试最值得讲的有 5 个:去冗余、拆表、二进制化、压缩、按需加载。

另外要特别区分:"文件体积大" ≠ "运行时内存一定大"。

例如 JSON 用文本表示非常浪费,但加载后可以转换成紧凑的 C# 数据结构,或者直接使用二进制数据,就能明显降低内存。

面试口述总结

游戏中的 Excel 表优化,一般不会只考虑 Excel 本身,而是从 Excel 到客户端的数据链路整体优化。

如果导出的 JSON 或 Lua 很大,首先会检查是否存在大量重复字符串、重复字段和无用策划字段,然后通过 ID 化、枚举化、字符串字典以及拆表减少冗余。对于数据量特别大的项目,可以从 JSON/Lua 文本格式进一步升级到 Protobuf、MessagePack 或自定义二进制格式,减少包体和解析成本。

如果主要问题是运行时内存,则还需要按模块、场景进行拆分和按需加载,不能把所有配置一次性加载。Lua 配置还要注意大量 Table 和 String 带来的内存开销。最终通过 Profiler 和 Memory Profiler 验证优化效果。


18. Unity中接SDK,一般怎样通信

Unity 接 SDK,本质上是:Unity C# 层与原生平台 SDK 或第三方服务之间建立一层通信桥接。

如果是 Android / iOS 游戏,最常见的架构是:

复制代码
                Unity C#
                   │
             SDK Wrapper层
                   │
        ┌──────────┴──────────┐
        ↓                     ↓
     Android                iOS
     Java/Kotlin            Objective-C/Swift
        │                     │
        ↓                     ↓
    原生 SDK                原生 SDK

Unity 自己一般不会直接和 SDK 的底层实现通信,而是通过:C# Wrapper → Native Bridge → SDK

面试中记住这句话:Unity 接 SDK,核心就是跨语言、跨平台通信,Android 主要通过 JNI,iOS 主要通过 Objective-C/Objective-C++ 层进行桥接,再通过回调把结果返回 Unity。

面试口述总结

Unity 接 SDK,本质上是 Unity C# 和 Android、iOS 原生环境之间做跨语言通信。

通常会在 Unity 和 SDK 之间增加一层 SDK Wrapper 和 Native Bridge。

Android 主要通过 JNI,也就是 AndroidJavaClass、AndroidJavaObject 等方式调用 Java 或 Kotlin SDK;

iOS 一般通过 DllImport("__Internal") 调用 Objective-C 或 Objective-C++ 的桥接接口。

SDK 的结果再通过 Native Callback 回传 Unity,最终转换成 C# 的 Event 或 Delegate 给业务层使用。实际项目中需要注意参数序列化、Native 回调线程、生命周期和平台差异,同时把登录、支付、广告等 SDK 封装成统一接口,避免业务层直接依赖原生代码。

对于支付这类涉及资产发放的 SDK,客户端回调不能作为最终依据,应该由服务器校验订单。


19. Unity 中单例(Singleton)的正确写法有哪些?各有什么注意点?

答案:

1. 纯 C# 单例(非 MonoBehaviour)

csharp 复制代码
public class GameConfig
{
    private static GameConfig _instance;
    private static readonly object _lock = new object();

    public static GameConfig Instance
    {
        get
        {
            if (_instance == null)
            {
                lock (_lock)   // 双检锁保证线程安全
                {
                    if (_instance == null)
                        _instance = new GameConfig();
                }
            }
            return _instance;
        }
    }
}

2. MonoBehaviour 单例(挂载在场景中)

csharp 复制代码
public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }

    void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);   // 处理重复实例
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);  // 跨场景保留
    }
}

3. 泛型 MonoBehaviour 单例基类

csharp 复制代码
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T _instance;
    public static T Instance
    {
        get
        {
            if (_instance == null)
                _instance = FindObjectOfType<T>();
            return _instance;
        }
    }
}

注意点:

  • MonoBehaviour 单例不能 new ,必须场景里有物体挂载(或运行时 AddComponent)。
  • 跨场景要 DontDestroyOnLoad,并处理切换场景时重复创建的问题。
  • 多线程访问时考虑 lock。
  • 单例本质是全局状态 + 隐藏依赖 ,难测试、难管理生命周期、易造成耦合,不要滥用;能用依赖注入替代时优先用 DI。

20. 观察者模式 / 事件系统在 Unity 中如何实现?

答案:

观察者模式:被观察者(主题)状态变化时,主动通知所有订阅的观察者,实现发送方与接收方解耦。

1. C# event / delegate

csharp 复制代码
public class Player : MonoBehaviour
{
    public static event Action<int> OnHealthChanged;

    private int _health;
    public int Health
    {
        get => _health;
        set
        {
            _health = value;
            OnHealthChanged?.Invoke(_health);  // 通知订阅者
        }
    }
}

public class UIHealth : MonoBehaviour
{
    void OnEnable() => Player.OnHealthChanged += UpdateUI;
    void OnDisable() => Player.OnHealthChanged -= UpdateUI;  // 必须退订

    void UpdateUI(int hp) { /* 刷新 UI */ }
}

2. UnityEvent(可在 Inspector 配置)

csharp 复制代码
public class ButtonHandler : MonoBehaviour
{
    public UnityEvent onClick;
    void Update() { if (Input.GetKeyDown(KeyCode.Space)) onClick?.Invoke(); }
}

3. 自定义事件中心(EventManager)

csharp 复制代码
public static class EventManager
{
    private static Dictionary<EventType, Action<object>> _events = new();

    public static void Register(EventType type, Action<object> handler) { ... }
    public static void Unregister(EventType type, Action<object> handler) { ... }
    public static void Fire(EventType type, object data = null) { ... }
}

注意点(面试重点):

  • 订阅后一定要退订 (OnDisable/OnDestroy),否则对象被销毁后仍被事件持有,导致内存泄漏。
  • 避免滥用全局事件中心导致"事件满天飞"、难以追踪调用链。

21. 状态机模式(State Machine)在游戏中的用途和实现?

答案:

用途: 角色状态(待机/移动/攻击/死亡)、AI 行为、动画逻辑、UI 流程、战斗流程等。

好处: 把大量 if-else / switch 拆成独立的状态类,逻辑清晰、易扩展、易维护,新增状态不用改动原有大量分支。

实现方式:

1. 简单:枚举 + switch

csharp 复制代码
enum State { Idle, Move, Attack }
void Update()
{
    switch (state)
    {
        case State.Idle: /* ... */ break;
        case State.Move: /* ... */ break;
    }
}

适合状态很少的简单场景。

2. 状态模式:状态基类 + 状态对象

csharp 复制代码
public abstract class State
{
    public abstract void Enter();
    public abstract void Update();
    public abstract void Exit();
}

public class IdleState : State { /* ... */ }
public class MoveState : State { /* ... */ }

public class StateMachine
{
    private State _current;
    public void ChangeState(State next)
    {
        _current?.Exit();
        _current = next;
        _current?.Enter();
    }
    public void Update() => _current?.Update();
}

适合状态较多、每个状态逻辑复杂的场景。

3. 使用 Animator 状态机 / 第三方插件(如 Animancer、StateMachine 插件)管理动画与逻辑状态。


22. MVC、MVP、MVVM 三种 UI 架构的区别?Unity 中如何选择?

答案:

架构 组成 数据流 特点
MVC Model(数据)、View(显示)、Controller(逻辑/输入) View → Controller → Model → View View 与 Model 通过 Controller 交互,View 可能直接观察 Model
MVP Model、View(被动)、Presenter(所有逻辑) View → Presenter → Model → Presenter → View View 只负责显示和转发输入,逻辑全在 Presenter,可测试性好
MVVM Model、View、ViewModel(数据绑定) View ↔ ViewModel ↔ Model 用数据绑定自动同步,View 几乎无逻辑,需要绑定框架

Unity 中如何选择:

  • 小项目 / 简单 UI:直接 MonoBehaviour,别过度设计。
  • 中等项目:MVP(View 与逻辑分离,易测试)。
  • 大型项目 / 复杂动态 UI:MVVM + 绑定框架(如 UniRx、MVVM 插件),减少手动同步代码。

共同目标: 数据与表现分离,降低耦合,方便测试和维护(呼应 UI 篇"UI 开发效率提升"题)。


23. 工厂模式(Factory Pattern)在游戏开发中的用途?

答案:

工厂模式:把对象的创建 逻辑封装到工厂中,让调用方不直接 new,实现"创建"与"使用"解耦。

游戏中的典型用途:

  • 按类型创建敌人/道具/子弹(根据枚举或配置返回不同对象)。
  • 工厂 + 对象池结合:工厂负责"生产",对象池负责"复用"。
  • 跨平台资源创建、根据配置生成不同组件组合。

几种工厂:

类型 说明
简单工厂 一个工厂方法根据参数返回不同产品,缺点是新增产品要改工厂
工厂方法 每个产品对应一个工厂,通过继承扩展,符合开闭原则
抽象工厂 创建一族相关产品(如整套 UI 皮肤),适合产品族切换

示例(简单工厂):

csharp 复制代码
public class EnemyFactory
{
    public static Enemy Create(EnemyType type)
    {
        return type switch
        {
            EnemyType.Melee  => new MeleeEnemy(),
            EnemyType.Ranged => new RangedEnemy(),
            _ => throw new ArgumentOutOfRangeException()
        };
    }
}

24. 依赖注入(Dependency Injection)是什么?Unity 中用什么实现?

答案:

依赖注入(DI):对象的依赖由外部传入 ,而不是在内部自己 new,从而降低耦合、便于替换和测试。

csharp 复制代码
// 不推荐:内部直接创建依赖
public class Player
{
    private IWeapon _weapon = new Sword();   // 强耦合
}

// 推荐:通过构造/属性注入
public class Player
{
    private IWeapon _weapon;
    public Player(IWeapon weapon) { _weapon = weapon; }   // 构造注入
}

注入方式: 构造注入、属性注入、方法注入。

Unity 常用 DI 框架: Zenject / Extenject 、VContainer 、StrangeIoC。

好处:

  • 解耦、易替换实现(如换武器、换数据源)。
  • 便于单元测试(可注入 Mock)。
  • 集中管理对象生命周期(框架负责绑定和创建)。

代价:

  • 学习成本、配置代码变多。
  • 小项目可能过度设计。

注意: MonoBehaviour 的实例由引擎创建,不能直接构造注入,通常用属性注入或框架在 Awake 后注入。


25. 什么是二叉堆(Binary Heap)?它在游戏开发中有什么用途?

答案:

二叉堆是一种完全二叉树,满足堆性质(大顶堆:父节点 ≥ 子节点;小顶堆:父节点 ≤ 子节点)。常用于高效获取最大值或最小值。

用数组存储(不需要指针),索引关系:

  • 父节点:(i - 1) / 2
  • 左子节点:2 * i + 1
  • 右子节点:2 * i + 2

时间复杂度:

操作 复杂度
取堆顶(最大/最小) O(1)
插入 O(log n)
删除堆顶 O(log n)
建堆 O(n)

游戏中的用途:

  • A 寻路的 openList *:每次取 F 值最小的节点,用小顶堆可把"取最小"从 O(n) 降到 O(log n)。
  • 优先队列、事件调度(按时间优先执行)。
  • 排行榜 TopK、AI 目标优先级选择。

26. 什么是 LRU(Least Recently Used)缓存?如何实现?

答案:

LRU:当缓存容量已满时,淘汰最近最少使用的那条数据。是缓存、资源管理里最常用的淘汰策略之一。

实现:哈希表 + 双向链表,做到 O(1) 的查找、插入、删除:

  • 哈希表:key → 链表节点,O(1) 定位。
  • 双向链表:维护访问顺序,链表头是最近使用,链表尾是最久未使用。
  • 访问一个 key:把它移到链表头。
  • 插入新 key:放链表头,若超容量则删除链表尾节点。
csharp 复制代码
public class LRUCache
{
    private int _capacity;
    private Dictionary<int, LinkedListNode<(int key, int val)>> _map;
    private LinkedList<(int key, int val)> _list;
    // Get: 命中则移到头部;Put: 插入头部,满则移除尾部
}

游戏中的用途: 纹理/资源缓存、对象池的淘汰、网络资源缓存、动画数据缓存。


27. 常见的排序算法有哪些?各自的时间复杂度和适用场景?

答案:

算法 平均时间 最坏时间 空间 稳定 特点
冒泡 O(n²) O(n²) O(1) 稳定 简单,慢
选择 O(n²) O(n²) O(1) 不稳定 交换少
插入 O(n²) O(n²) O(1) 稳定 小规模/基本有序时很快
快排 O(n log n) O(n²) O(log n) 不稳定 实际最快,最坏退化
归并 O(n log n) O(n log n) O(n) 稳定 稳定,适合外部排序
堆排序 O(n log n) O(n log n) O(1) 不稳定 原地,适合 TopK

关键概念:

  • 稳定性:相等元素的相对顺序在排序后保持不变(归并稳定,快排不稳定)。
  • C# 的 List<T>.Sort() 底层是 Timsort(归并 + 插入的混合,稳定)。

游戏中的用途: 排行榜排序、渲染顺序排序、AI 目标优先级排序、匹配系统。


28. 图的遍历(BFS 和 DFS)在游戏开发中的应用?

答案:

遍历 数据结构 特点 用途
BFS(广度优先) 队列 逐层向外扩展,能找到无权图最短路径 最短路径、连通性检测、A* 的基础
DFS(深度优先) 栈(或递归) 沿一条路走到底再回溯 连通分量、拓扑排序、迷宫生成、回溯

伪代码:

csharp 复制代码
// BFS
void BFS(Node start)
{
    var queue = new Queue<Node>();
    var visited = new HashSet<Node>();
    queue.Enqueue(start); visited.Add(start);
    while (queue.Count > 0)
    {
        var cur = queue.Dequeue();
        foreach (var next in cur.neighbors)
            if (visited.Add(next)) queue.Enqueue(next);
    }
}

游戏中的用途:

  • BFS:网格地图上无权最短路径、判断地图区域是否连通、洪水填充(区域标记)。
  • DFS:生成迷宫、寻找连通分量、拓扑排序(依赖顺序)、回溯求解(如数独、推箱子)。
  • 带权图的最短路径则在 BFS 基础上演化为 Dijkstra / A*。

29. C# 中值类型(struct)和引用类型(class)的区别?什么场景用 struct?

答案:

对比 值类型(struct) 引用类型(class)
存储 栈(或内联在对象中) 堆
赋值 复制整个值 复制引用(指向同一对象)
空值 默认非 null(可 Nullable<T>) 可为 null
继承 不能继承(可继承接口) 可继承
装箱 传给 object/接口参数会装箱 无

用 struct 的好处: 无堆分配、无 GC 压力、内存连续(缓存友好)。

用 struct 的代价: 赋值复制(大 struct 开销大)、装箱风险、不能继承。

适用场景:

  • 小而频繁创建的数据:Vector3、Color、自定义坐标、ECS 组件。
  • 高频热路径中需要避免 GC 的对象。

注意: struct 不宜过大(复制开销)、尽量不可变、避免内部持有引用类型字段导致"浅拷贝陷阱"。

面试口述简短版本

值类型主要是 struct 和 enum,变量存储真实的数据,赋值的时候拷贝完整一份,大多在栈上,一般不会触发 GC;引用类型比如 class、List,变量存的是内存地址,真正对象存在托管堆,赋值只是复制地址,多个变量指向同一个对象,由 GC 回收。

参数传递的时候,值类型默认传副本,函数内修改不影响外部;引用类型传的是地址副本,可以修改对象内部成员,但函数内部 new 一个新对象不会改变外面。

装箱就是把值类型包装成引用类型放到堆,会产生 GC,开发尽量避免。Unity 中 MonoBehaviour 是 class 引用类型,Destroy 之后如果还持有引用,会发生内存泄漏;Vector3 这些结构体是值类型。


30. C# 的 GC 分代机制是什么?什么是大对象堆(LOH)?

答案:

分代 GC: 托管堆按对象存活时间分 3 代:

代 说明
第 0 代 新分配的对象,回收最频繁、最廉价
第 1 代 从第 0 代存活下来的对象
第 2 代 长期存活的对象,回收最昂贵、最少发生

对象先分配在第 0 代,每次 GC 存活后晋升到下一代。回收第 0 代最快,所以减少短命对象的分配能大幅降低 GC 开销。

大对象堆(LOH):

  • 大于 85000 字节的对象直接分配在 LOH。
  • LOH 不进行压缩 (只标记-清除),容易产生内存碎片。
  • 频繁创建/销毁大对象(大数组、大字符串)会导致碎片化、内存虚高。

对游戏开发的意义:

  • 避免在每帧频繁分配新对象(GC 会 stop-the-world 造成卡顿)。
  • 大数组/大缓冲区用对象池复用,避免 LOH 碎片。
  • 用 struct、Span<T>、对象池减少堆分配。

31. C# 中委托(delegate)、事件(event)和闭包(closure)的内存模型和注意事项?

答案:

委托(delegate): 持有「目标对象引用 + 方法指针」。委托引用的对象会被委托持有,只要委托还在,对象就不会被 GC 回收。

事件(event): 本质是对委托的封装,限制委托只能在声明它的类内部触发(Invoke),防止外部随意触发,更安全。

闭包(closure): 捕获外部变量的匿名函数/lambda,编译器会生成一个闭包类,把捕获的变量变成类的字段,因此:

  • 捕获的变量生命周期被延长(只要闭包存活,变量就不释放)。
  • 每次创建闭包会分配一个闭包对象,产生 GC。

注意事项(面试重点):

  • 委托/事件订阅后必须退订 (OnDisable/OnDestroy),否则对象被事件长期持有 → 内存泄漏。
  • 循环里创建 lambda 要小心闭包捕获 ,特别是捕获循环变量(C# 5+ 的 foreach 已修复,for 仍需注意)。
  • 闭包捕获大对象会导致对象迟迟无法释放。

32. 如何在 Unity 中用 struct 做内存优化?有什么陷阱?

答案:

优化方式:

  1. 用 struct 替代小的 class :减少堆分配和 GC(如 Vector2Int、自定义小数据)。
  2. 数组存 struct :MyStruct[] 内存连续,缓存友好,遍历快。
  3. 配合 Job System / Burst :Job 里用 NativeArray<T> 存 struct,多线程 + 无 GC。
  4. ECS 的核心思想:组件就是纯数据 struct,连续内存,几乎零 GC。

陷阱:

  • 装箱 :把 struct 传给 object、接口、非泛型集合(如 ArrayList、object 参数)会装箱,反而产生 GC。
  • 大 struct 复制开销:struct 赋值是整体复制,struct 过大(几十字节以上)时传参/赋值开销明显。
  • 可变性陷阱 :list[i].x = 1 这种直接改 struct 字段的写法实际改的是副本(返回的是拷贝),需要整体赋值 list[i] = new Vector3(...)。
  • struct 里含引用类型字段:复制时只复制引用,容易造成浅拷贝理解偏差。

经验: struct 应保持小、不可变(immutable),并在热路径中批量使用才能体现优势。


三、UI / UGUI 篇

1. 一个 UI 页面打开时明显卡顿,从 Profiler 里看到 Canvas.BuildBatch 和 Layout.Rebuild 很高,你会怎么分析?

答案:

Canvas.BuildBatch 高

表示 UI 网格的合并与重建开销大。

  • 分析 :检查该 Canvas 下的 UI 元素数量是否过多;是否有复杂的 Mask、RectMask2D;是否频繁地改变 UI 元素的 Active、位置、大小;是否有大量文本或图片频繁更新。
  • 优化 :将动态变化的 UI 与静态 UI 分离到不同 Canvas;减少 Mask 使用;避免在 Update 中修改 UI 属性;使用 CanvasRenderer.cull 手动剔除不可见元素。

Layout.Rebuild 高

表示布局重新计算的开销大。

  • 分析 :检查是否使用了大量的 LayoutGroup(如 HorizontalLayoutGroup、VerticalLayoutGroup),且子对象频繁变化(动态添加/删除)。特别是嵌套布局时计算量更大。
  • 优化 :对于静态布局,初始化后禁用布局组件;使用 ContentSizeFitter 时要谨慎,其开销较大;可以手动调用 LayoutRebuilder.ForceRebuildLayoutImmediate 控制刷新时机;考虑使用 RectTransform 直接设置位置和大小。

排查步骤:

  1. 在 Profiler 中切换到 UI 模块,查看具体哪些 UI 元素引起了重建。
  2. 使用 Frame Debugger 查看渲染批次,判断是否有过多的 DrawCall。
  3. 尝试拆分 Canvas:将常驻 UI 和动态 UI 分离,将频繁更新的 UI 放到独立 Canvas。
  4. 禁用不必要的 Raycast Target,减少交互检测开销。

2. Unity 中 UGUI 优化相关注意点有哪些?(至少说出 3 点)

答案:

  1. Canvas 拆分:将常驻 UI 和动态 UI 分到不同的 Canvas,避免整个 Canvas 重建。将频繁变化的 UI 放在独立的 Canvas 下。
  2. 避免使用 Mask :Mask 组件会增加 DrawCall 和 Overdraw。尽量使用 RectMask2D,它不会产生额外的渲染批次,但限制较多。
  3. UI 网格的生成与重建:减少 UI 元素数量的变化、位置和大小的变化,这些变化会触发网格重建。
  4. 图片与图集 :使用图集,将小图合并,设置合适的 Image.Type,避免使用 Simple 时进行缩放。
  5. 字体 :使用 TextMeshPro 替代 UnityEngine.UI.Text,其渲染效率更高,内存占用更小,功能更强大。
  6. 禁用不可见的 UI :将不可见的面板设置为 SetActive(false) 而不是仅改变透明度,这样可以避免它们参与网格重建和渲染。

3. UGUI 渲染过程中相机裁切(裁剪)在哪一步?

答案:

UI 的裁切(Culling)发生在 Canvas 的网格生成阶段之前。Canvas 会根据 UI 元素与摄像机的视锥体(或者与 RectTransform 的边界)进行计算,决定哪些 UI 元素需要被渲染。如果一个 UI 元素完全在视锥体之外,它就不会生成对应的渲染网格,也就不会参与后续的 DrawCall。

对于 ScrollRect 内的元素,可以通过设置 RectMask2D 组件来提前剔除不可见的元素。RectMask2D 会生成一个裁剪区域,超出该区域的 UI 元素不会被渲染,从而减少 Overdraw。

具体流程:

  1. Canvas 收集所有 UI 元素。
  2. 根据相机视锥体或 Mask 进行可见性判断。
  3. 生成可见 UI 元素的网格数据。
  4. 将网格数据提交给 GPU 渲染。

4. 如何在 UGUI 中处理不同分辨率和屏幕比例的适配?

答案:

使用 Canvas Scaler 组件进行适配。

主要设置:

  • UI Scale Mode 选择 Scale With Screen Size。
  • 设置一个参考分辨率(Reference Resolution),例如 1920x1080。
  • Screen Match Mode 用于选择匹配宽度、高度或两者:
    • Expand:按宽高比例缩放,确保 UI 完全显示,可能会有留白或溢出,但保持比例。
    • Shrink:按比例缩放,使 UI 完全适应屏幕,可能裁剪部分内容。
    • Match Width or Height:可手动调整宽度和高度匹配的权重。

辅助适配:

  • 锚点(Anchor):设置 UI 元素的锚点位置,使其相对于父容器不同位置(如左上角、中心等)。
  • Aspect Ratio Fitter:强制保持 UI 元素的宽高比。
  • Content Size Fitter:根据内容自动调整尺寸。
  • Layout Group:自动排列子元素,适应不同尺寸。

示例:

  • 背景图使用 Image 组件,设置 Anchor Presets 为全拉伸。
  • 按钮固定在屏幕左下角,锚点设为左下角。
  • 文字使用 TextMeshPro,并设置 Auto Size 自动缩放字体。

5. UGUI 中如何做到点击按钮时,不要响应其他 Input 相关的检测?

答案:

  1. 使用 Event System 的 Raycaster 优先级 :确保按钮所在的 Canvas 的 Graphic Raycaster 组件优先级高于其他可点击对象(按 Canvas 顺序)。
  2. 使用 Event Trigger 组件 :在按钮上添加 Event Trigger,只监听 PointerClick 事件,其他事件(如 Drag)不做处理。
  3. 控制交互性 :在按钮点击时,将其他可能会响应的 UI 组件的 interactable 设置为 false,或禁用它们的 Raycast Target。
  4. 使用 Canvas Group :将整个需要屏蔽点击的区域放置在一个 Canvas Group 下,设置其 interactable 为 false 或 blocksRaycasts 为 false。
  5. 使用 Input Module 的阻断 :在某些复杂场景下,可以临时禁用 StandaloneInputModule 或 TouchInputModule,但这会禁用所有输入,一般不推荐。

6. 如何为 UGUI 中的某一个控件添加自定义事件监听(比如为一个 Image 添加点击事件)?

答案:

方法一:通过 Inspector 添加 Button 组件

  • 在 Image 上添加 Button 组件(Button 本身就是 Image 的子类,但 Button 会自动处理点击事件),然后在 Button 的 OnClick() 事件列表中绑定方法。

方法二:通过脚本添加 EventTrigger

csharp 复制代码
using UnityEngine;
using UnityEngine.EventSystems;

public class AddClickListener : MonoBehaviour
{
    void Start()
    {
        EventTrigger trigger = GetComponent<EventTrigger>();
        if (trigger == null)
            trigger = gameObject.AddComponent<EventTrigger>();

        EventTrigger.Entry entry = new EventTrigger.Entry();
        entry.eventID = EventTriggerType.PointerClick;
        entry.callback.AddListener((data) => { OnImageClicked(); });
        trigger.triggers.Add(entry);
    }

    void OnImageClicked()
    {
        Debug.Log("Image clicked!");
    }
}

方法三:使用 IPointerClickHandler 接口

csharp 复制代码
public class MyImage : MonoBehaviour, IPointerClickHandler
{
    public void OnPointerClick(PointerEventData eventData)
    {
        Debug.Log("Clicked!");
    }
}

7. 我们在进行 UI 开发时,每个面板都会有很多控件(Button、Toggle、Slider 等等),每新写一个面板逻辑,都会为这些控件做一些相同的事情,比如:声明控件、查找控件、监听控件等等。请问:我们应该如何提升我们的开发效率,让这些事情不用每次都去做?(至少说出两种方案)

答案:

  1. 使用 UI 框架(MVC/MVP/MVVM):建立一套 UI 基类,将查找控件、绑定事件、初始化状态的通用逻辑封装在基类中。具体面板只需继承基类,并声明自己的控件字段即可。例如:

    csharp 复制代码
    public abstract class UIBase : MonoBehaviour
    {
        protected virtual void Awake()
        {
            BindUIElements();
        }
        protected abstract void BindUIElements();
        // 通用方法:FindComponent、AddListener 等
    }
  2. 自动绑定工具 :编写编辑器扩展,通过代码生成器或反射,在运行时自动根据命名规则(如 Button_Close)查找并绑定控件,并自动注册事件。例如:

    csharp 复制代码
    [UIElement("Button_Close")]
    private Button closeButton;

    通过反射遍历字段,使用 GameObject.Find 或 GetComponent 自动赋值。

  3. 使用 UI 管理插件 :如 UGUI Binding 、UIWidgets 等第三方插件,它们提供了数据绑定和自动查找功能。

  4. 使用 Visual Scripting:在编辑器中使用可视化脚本(如 Bolt)进行 UI 逻辑的快速搭建,减少手写代码。


8. Unity 中 UI 的 DrawCall 合批原理是什么?哪些因素会破坏合批?

答案:

合批原理:

UI 元素最终由 Canvas 生成网格数据,提交给 GPU。多个 UI 元素如果使用相同的材质(通常是图集材质)且渲染顺序连续,它们可以被合并到一个 DrawCall 中渲染。Canvas 会按深度(Hierarchy 顺序或 Z 轴)排序后,尝试将相邻且材质相同的元素合并。

破坏合批的因素:

  • 不同的材质:使用不同图集、不同 Shader 或不同 Material 实例。
  • 渲染顺序被阻断:在两个相同材质的元素之间插入了一个不同材质的元素,导致批处理中断。
  • 使用 Mask 组件:Mask 会创建新的渲染队列,中断批处理。
  • 使用 Canvas 嵌套 :不同 Canvas 之间无法批处理(除非设置 Override Sorting 但通常不推荐)。
  • 使用 CanvasRenderer.cull 或 RectMask2D:虽然不直接破坏批处理,但可能因裁剪导致元素被拆分。
  • Layout 变化:动态改变元素位置、大小或层级可能导致网格重建,进而影响批处理连续性。

优化建议:

  • 尽可能使用同一图集,将 UI 元素按图集分组到不同 Canvas。
  • 避免在 UI 元素之间插入不同材质的元素,可通过调整 Hierarchy 顺序来合并同材质元素。
  • 使用 Sprite Atlas 管理图集,并启用 Include in Build。

9. Unity 中 Transform 的 localPosition 和 RectTransform 的 anchoredPosition 有什么区别?

答案:

属性 所属 含义
localPosition Transform 相对于父级变换的局部坐标位置,以父级原点为基准,单位是 Unity 世界单位(米)。适用于普通 3D 物体和 UI(如果父级也是 RectTransform,则仍为局部坐标)。
anchoredPosition RectTransform 相对于锚点的位置,单位是像素(取决于 Canvas 缩放)。锚点决定了该位置的计算基准。如果锚点聚集在一点,则 anchoredPosition 表示该点相对于锚点的偏移;如果锚点分散,则用于定义拉伸区域的偏移。

区别:

  • localPosition 只关心父子变换的平移,不涉及锚点和矩形尺寸。
  • anchoredPosition 是 UI 专用,与 Canvas 缩放、锚点、轴心点相关,常用于动态调整 UI 元素位置。
  • 对于 RectTransform,修改 localPosition 可能会破坏 UI 布局预期,因为 RectTransform 的位置计算还涉及锚点和轴心。修改 anchoredPosition 是推荐方式。

10. Image / RawImage 区别

Image 接收 Sprite,支持九宫格、填充模式,可以参与 SpriteAtlas 图集合批;RawImage 接收原始 Texture、RenderTexture,不支持九宫格,不参与图集合批,多用于视频、截图、网络图片。

对比 Image RawImage
资源类型 Sprite Texture2D / RenderTexture
九宫格 Sliced ✅支持 ❌不支持
Tiled / Filled ✅支持 ❌不支持
参与图集合批 ✅可以 ❌不参与 SpriteAtlas
网格 Mesh 可细分网格(九宫格) 简单 Quad 面片
用途 UI 图标、按钮、弹窗背景 RT、视频、网络图片、截图

11. LayoutGroup(Horizontal/Vertical/GridLayoutGroup)性能有哪些坑需要注意

LayoutGroup 性能开销来自 Layout Rebuild 布局重建;只要任意子元素变脏,整个 LayoutGroup 所有子节点全部重新计算位置尺寸。

  1. 嵌套 LayoutGroup 性能很差,子元素脏标记会向上遍历每一层布局组件,多层递归重建;
  2. 子物体改文本、显隐、增删、滚动都会触发全量重建;ScrollRect 的 Content 挂 LayoutGroup,滚动每帧重建,造成滑动卡顿;
  3. LayoutGroup 搭配 ContentSizeFitter 容易出现循环多次布局,放大开销;
  4. 大量子物体不适合 LayoutGroup,长列表要使用对象池。

优化:尽量减少使用,禁止多层嵌套;静态界面布局完成后直接 Disable 布局组件。


四、渲染篇

1. 如何优化光带来的开销?

答案:

1. 静态光照(Baked Lighting)

  • 对于不会移动的物体,使用烘焙光照,将光照信息存储到 Lightmap 中,运行时不再进行实时光照计算。
  • 使用 Light Probes(光照探针)为动态物体提供来自烘焙场景的光照信息。

2. 实时光照优化

  • 限制实时光源的数量和范围。点光源设置合理的 Range,避免影响过多物体。
  • 使用 Cookie 纹理模拟复杂光照效果,但需注意贴图开销。
  • 调整阴影设置:降低阴影贴图分辨率、缩短阴影距离、使用软阴影但注意开销。移动端尽量减少或关闭实时阴影。

3. 渲染路径选择

  • 移动端使用 Forward Rendering,并设置 Per-Pixel Light Count 限制逐像素光源个数。
  • PC/主机端可使用 Deferred Rendering 处理大量光源,但注意带宽和显存开销。

4. Shader LOD

  • 为不同距离的物体使用不同的 Shader,远处的物体使用简化版本,不计算复杂光照。

5. 使用 Lightmap 和 Light Probe 组合

  • 动态物体用 Light Probe 获取间接光,避免实时计算。

6. 剔除

  • 启用遮挡剔除(Occlusion Culling),使不可见物体不参与光照计算。

2. DrawCall、SetPass、Overdraw、Fillrate (填充率) 分别是什么?优化手段有哪些?

答案:

术语 定义 主要影响 优化手段
DrawCall CPU 向 GPU 发出的一次渲染调用,包含网格、材质、纹理等渲染命令 CPU 性能(过多 DrawCall 使 CPU 忙于准备和发送渲染指令,成为瓶颈) 合批(Batching)、图集、减少材质数量、使用 SRP Batcher
SetPass GPU 切换渲染状态(如 Shader Pass)的次数 CPU/GPU 状态切换开销 减少材质种类、减少 Shader 变体、使用 SRP Batcher
Overdraw 同一像素被重复绘制的次数(如多个透明物体重叠时,每个物体都会绘制该像素) GPU 性能(填充率)(高 Overdraw 迫使 GPU 反复计算像素颜色) 减少透明物体重叠、优化粒子系统、合理 UI 层级、使用遮挡剔除
Fillrate GPU 单位时间内能处理的像素数量,受分辨率和复杂 Shader 影响 GPU 性能 降低渲染分辨率、简化 Fragment Shader、减少 Overdraw、使用 LOD

3. Static Batching、Dynamic Batching、GPU Instancing、SRP Batcher 各自的限制是什么?

答案:

技术 限制
Static Batching 物体必须是静态的;合并后的网格顶点数不能超过 64k(索引格式 16 位);会增加内存和磁盘大小(合并后的网格)。
Dynamic Batching 顶点数一般限制在 300 个以内;使用相同的材质;Scale 不能为负;不支持 Skinned Mesh Renderer;使用多 Pass Shader 的物体会破坏批处理。
GPU Instancing 材质必须相同;Mesh 必须相同;每个实例的数据需单独传入;在某些平台上实例数量有限制;不支持 Skinned Mesh Renderer(需用其他方法)。
SRP Batcher 仅适用于可编程渲染管线(URP、HDRP);材质必须兼容 SRP Batcher(Shader 属性结构需与内置缓冲区一致)。

4. 简单解释深度测试、深度写入、透明排序为什么容易出渲染问题?

答案:

  • 深度测试:比较当前像素的深度值与深度缓冲区中已存储的深度值,决定是否绘制该像素。
  • 深度写入:如果通过深度测试,将当前像素的深度值写入深度缓冲区。

透明排序问题:

如果先渲染远处的透明物体,再渲染近处的透明物体,由于深度测试的存在,近处物体的像素可能会被已经存在的远处物体深度值阻挡,导致远处物体看起来"穿透"了近处物体。

正确顺序:先渲染所有不透明物体(写入深度),然后从后往前渲染透明物体(不写入深度或谨慎写入)。


5. Shader 和 Compute Shader 的区别是什么?

答案:

类型 用途 特点
Shader 用于图形渲染管线的各个阶段(顶点、片段、几何等) 输入是顶点数据和纹理,输出是屏幕上的像素颜色;与渲染上下文强绑定。
Compute Shader 独立于渲染管线,用于通用计算(GPGPU) 处理抽象的数据集,可以在任意数量的线程上并行运行;适合粒子系统更新、后处理特效的复杂计算、大规模数据的并行处理等。

6. 纹理中通道合并指的是什么?

答案:

通道合并是指将不同的信息存储到一张纹理的不同通道(R, G, B, A)中,以减少纹理数量和采样次数。

示例:

  • 一张纹理的 R 通道存 Albedo 颜色,G 通道存 Roughness,B 通道存 Metallic,A 通道存 AO。
  • 这样一次采样就能获取多种表面属性,而不是分别采样 4 张纹理,极大地减少了 GPU 开销和内存。

7. 光照探针有什么作用?

答案:

光照探针(Light Probes)存储了烘焙场景中的光照信息(颜色、强度、方向)。它们用于为动态物体提供光照,使其看起来与烘焙场景的光照环境融为一体。当一个动态物体进入一个由多个探针包围的空间时,Unity 会对其位置进行插值,获取该位置的光照信息。


8. 反射探针有什么作用?

答案:

反射探针(Reflection Probes)捕捉其周围环境的 360 度全景图像(Cubemap),用于为场景中的物体提供反射效果,特别是对于金属或光滑表面的材质。它可以生成静态的 Cubemap(烘焙)或实时的 Cubemap,用于模拟环境反射。


9. 光照烘焙时混合光源类型的作用是什么?

答案:

混合光源(Mixed Lighting)允许场景中的光源既有实时光照的效果,又有静态烘焙的效果,是性能与动态效果的平衡。

  • Baked Only:完全烘焙,无实时开销,无动态效果。
  • Realtime:完全实时,开销大。
  • Mixed :
    • Subtractive:烘焙光照叠加实时阴影,开销较小,效果一般。
    • Baked Indirect:间接光烘焙,直接光实时,效果和性能的平衡。
    • Shadowmask:烘焙阴影贴图,支持远距离阴影,开销适中。

10. Shader 要怎么写才能更省 GPU(至少说出 3 点)?

答案:

  1. 使用 Shader LOD:为不同距离或画质设置不同的 Shader 版本,远距离使用简化版。
  2. 简化数学运算 :用 pow(x, 2) 代替 exp(2 * log(x));尽量避免动态分支,对简单的二选一可用 lerp、step 等无分支写法(减少 warp divergence 的风险)。
  3. 减少纹理采样次数:合并通道,使用贴图数组,复用采样结果。
  4. 使用 Half 精度 :在移动端,尽量使用 half 和 fixed 类型,而不是 float,因为移动 GPU 对 half 精度的运算更快。
  5. 降低 Overdraw :在 Fragment Shader 中尽早 discard 或使用 clip,避免无效的像素计算。
  6. 使用内置函数 :如 normalize、dot、reflect 等,它们通常有硬件加速。

11. 想要降低 GPU 处理的顶点数据有几种方式?(至少说出 3 种方式)

答案:

  1. LOD(Level of Detail):为模型创建多个不同精度的版本,根据距离切换,远处使用低模,减少提交给 GPU 的顶点数。
  2. 简化模型:在建模软件中优化模型,移除多余的边和面,减少顶点数量。
  3. 视锥剔除(Frustum Culling):确保只有摄像机视锥体内的物体才被提交渲染,自动减少顶点数。
  4. 遮挡剔除(Occlusion Culling):被其他物体完全遮挡的物体不参与渲染,进一步减少顶点数。
  5. 合并网格:将多个小网格合并成一个大的网格,虽然总顶点数不变,但可以减少 DrawCall 和顶点数据的上传次数。

12. URP 项目中 SRP Batcher 和 GPU Instancing 两种 DrawCall 优化方案应该如何选择?

答案:

方案 适用场景 原理 优先级
SRP Batcher 场景中有大量不同材质的物体,但材质兼容 SRP Batcher 将材质的属性持久化在 GPU 缓冲区中,避免每帧重新上传,大幅降低 CPU 端的 SetPassCall 开销 首选,对大多数场景都有明显效果且易于实现
GPU Instancing 场景中有大量相同材质、相同网格的物体(如大量树木、石头、敌人) 一次 DrawCall 绘制多个相同网格的实例,通过实例数据缓冲区传递每个实例的独立数据 当 SRP Batcher 无法进一步优化,且存在大量重复物体时启用

两者可以同时使用,分别优化不同类型的物体。


13. 动态批处理的限制条件是什么?至少说出 3 点。

答案:

  1. 网格顶点属性数需少于 900 个(不分平台);若每个顶点含 position + normal + uv 三个属性,约对应 300 个顶点。
  2. 所有物体必须使用同一个材质实例(材质内容必须完全相同)。
  3. 不能包含多 Pass 的 Shader(除非所有 Pass 都能被批处理)。
  4. 不能有镜像变换(Scale 负值)。
  5. 不能使用 Lightmap,不能接收实时阴影。
  6. 物体不能是 Skinned Mesh Renderer。

14. GPU Instancing 的原理是什么?

答案:

GPU Instancing 的原理是:CPU 向 GPU 发送一个 Mesh 的顶点缓冲区(Vertex Buffer)和一个索引缓冲区(Index Buffer),然后发送一个实例数据缓冲区(Instance Data Buffer),其中包含了每个实例的独立数据(如世界矩阵、颜色等)。GPU 接收到指令后,会使用这些数据,在单个 DrawCall 中渲染多个相同的 Mesh。Vertex Shader 中可以通过 unity_InstanceID 来从实例数据缓冲区中获取当前实例的数据。

关键点:

  • 每个实例的数据通过 Instancing 相关的宏(如 UNITY_INSTANCING_BUFFER_START、UNITY_DEFINE_INSTANCED_PROP)定义和访问。
  • 支持不同实例拥有不同的材质属性(如颜色、纹理偏移等),但网格必须相同。

15. 为什么 URP 管线在移动平台上表现比内置渲染管线好?至少说出 3 点。

答案:

  1. SRP Batcher:大幅减少了 CPU 端渲染状态的切换,这对于移动端有限的 CPU 性能至关重要。
  2. Shader 简化:URP 的 Shader 是专门为移动端和跨平台设计的,相比内置管线的标准 Shader,计算量更小,更高效。
  3. 单通道渲染:URP 的单通道(Single-Pass)Forward 渲染比内置管线的多通道(Multi-Pass)Forward 渲染更高效,减少了 DrawCall 和 SetPassCall。
  4. 更灵活的 Light Culling:URP 提供了更好的光照剔除算法,减少了无效的光照计算。
  5. 移动端优化预设:URP 的 Asset 预设可以针对不同移动设备进行优化,如降低阴影质量、简化后处理效果等。

16. 当一个场景中有大量相同的动态对象(如 1000 只敌人),如何优化它们的渲染?

答案:

  1. GPU Instancing:如果 1000 只敌人使用相同的 Mesh 和 Material,可以启用 GPU Instancing,将 DrawCall 从 1000 次降低到几十次。
  2. LOD:为敌人模型设置 LOD,远处的敌人使用低模,渲染负担小。
  3. 剔除:视锥剔除和遮挡剔除,确保不在镜头内的敌人不会被渲染。
  4. 动态合批:如果每个敌人的 Mesh 顶点数很少(如 300 个以内),且材质相同,动态批处理也可以起作用,但 1000 个敌人的动态批处理 CPU 开销可能很大,不如 GPU Instancing。
  5. Animator 优化 :使用 Animator.cullingMode 停止不可见敌人的动画更新。如果所有敌人的动画同步,可以考虑动画实例化技术。
  6. 对象池:管理敌人的创建和销毁,避免频繁 GC。

17. Unity Shader 中如何实现水面效果?2D 和 3D 的分别如何实现?

答案:

3D 水面:

  • 顶点动画:在 Vertex Shader 中,通过正弦波、余弦波或 Gerstner 波来扰动顶点 Y 轴位置,模拟水面波浪。
  • 反射:使用反射探针或实时渲染的反射相机,采样天空盒或周围环境。
  • 折射:通过 GrabPass 获取屏幕图像,然后对 UV 进行扭曲,模拟水下物体的扭曲效果。
  • 菲涅尔效应(Fresnel):根据视角与法线的夹角,混合反射和折射的颜色,边缘更亮。
  • 法线贴图:滚动法线贴图来模拟水面的波纹细节。

2D 水面:

  • 简单扰动:对 2D 精灵(Sprite)的顶点位置进行简单的 Y 轴扰动,或者对渲染纹理进行 UV 偏移和扭曲。
  • 遮罩:使用 Alpha 通道来控制透明区域的形状,通常表现为有动态高光的水面。
  • 使用 Shader Graph 或自定义 Shader 对 UV 进行偏移,产生流动感。

18. Unity 中前向渲染路径、顶点照明渲染路径、延迟渲染路径各自的优缺点是什么?

答案:

渲染路径 优点 缺点
前向渲染(Forward) 支持 MSAA,透明物体渲染友好,对低端硬件友好,内存占用低。 处理大量光源时性能差,每个物体只能被少数几个逐像素光源影响。
顶点照明(Vertex Lit) 性能最高,适合低端设备。 效果最差,不支持纹理法线、阴影等高级特性。已基本被废弃。
延迟渲染(Deferred) 能处理大量光源,性能与场景复杂度(光源数量)无关,与屏幕分辨率相关。效果丰富。 不支持 MSAA(需自行后处理抗锯齿),透明物体渲染复杂,内存占用高(需要 G-Buffer),对移动端硬件支持差。

19. Unity 中动态批处理和静态批处理的区别?

答案:

特性 动态批处理 静态批处理
适用对象 小网格(顶点数少)、相同材质的动态物体 标记为 Static 的静态物体(如建筑、地形)
运行时机 运行时,CPU 每帧进行合批计算 构建时(Build),预先合并网格
内存开销 无额外内存,但有 CPU 计算开销 增加磁盘和内存开销(存储合并后的大网格)
限制 顶点数限制(300),材质必须相同,Scale 不能为负等 物体不能移动/旋转/缩放,顶点数不能超过 64k
DrawCall 减少 将多个小网格合并到一个 DrawCall 中 将所有静态网格合并到一个/几个 DrawCall 中

20. Unity 发布后的应用程序的美术表现效果比在编辑器中看起来差,可能原因有哪些?

答案:

  1. Color Space:项目设置为 Gamma,而构建时目标平台可能默认或强制使用了 Linear 或其他色彩空间,导致颜色表现不一致。
  2. Quality Settings :构建时使用的 Quality 设置可能与编辑器不同。检查 Project Settings → Quality 中的默认 Quality Level 和针对目标平台的设置。
  3. 压缩纹理:构建时会对纹理进行压缩,压缩格式(如 ETC2、ASTC)可能导致颜色失真、细节模糊或出现块状伪影。
  4. 光照与阴影:构建时的光照贴图(Lightmap)压缩设置可能比编辑器中的预览质量低,或者阴影分辨率被降低。
  5. 渲染管线设置:如果使用 URP/HDRP,构建时的渲染管线 Asset 中的设置(如 MSAA、HDR、阴影质量)可能与编辑器不同。
  6. Shader LOD:构建时可能启用了 Shader LOD,导致使用了更低精度的 Shader 版本。
  7. 分辨率:构建后的游戏可能以较低的原生分辨率运行,或缩放后看起来更模糊。

21. Unity 中线性颜色空间和伽马颜色空间的主要区别是什么?

答案:

颜色空间 说明
伽马空间(Gamma) 基于 CRT 显示器的物理特性,编码时进行了伽马校正(幂函数)。计算时在伽马空间中进行,光照和颜色混合的结果可能不物理准确,看起来对比度更高,但简单,适合早期项目和移动设备。
线性空间(Linear) 基于物理的光照计算,所有着色计算在线性空间中进行。结果更物理准确,光照更真实,阴影更柔和,渐变更自然。需要渲染管线支持,需要纹理的 sRGB 标记正确。

区别:

  • 线性空间会正确计算光照衰减和颜色混合,避免伽马空间下的"暗部过暗、亮部过曝"问题。
  • 线性空间需要更多的渲染通道和计算,对移动设备性能要求更高。

22. 为什么线性的表现效果更好,Unity 默认还是使用的伽马呢?

答案:

  1. 历史原因:伽马空间是传统,很多旧项目、旧 Shader、旧美术资源都是为伽马空间设计的。
  2. 性能:线性空间需要更多的渲染通道和计算,对移动设备(尤其是旧设备)的性能开销较大。
  3. 兼容性:伽马空间对硬件和某些平台(如 WebGL)的兼容性更好。
  4. 简单:伽马空间的颜色混合规则更符合"直觉",美术人员可能更容易上手。
  5. 默认设置:内置渲染管线为了兼容性和性能,默认使用伽马空间;但现代 URP / HDRP 新项目模板默认已是线性空间。对于使用 URP/HDRP 的新项目,建议保持或切换到线性空间。

23. Shader 和材质球的关系是什么?

答案:

  • Shader 是一个"配方",描述了物体如何被渲染的算法和步骤(顶点变换、光照计算、纹理采样等)。
  • 材质球(Material) 是使用这个"配方"做出来的"一道菜",它将具体的参数(纹理、颜色、数值)填入 Shader 中。
  • 一个 Shader 可以创建出无数个 Material,每个 Material 可以有自己独特的外观。

关系类比: Shader 好比烘焙食谱,Material 好比按照这个食谱制作出的具体蛋糕,可以添加不同配料(纹理、颜色)。


24. 什么是纹理映射?

答案:

纹理映射是将一张 2D 图像(纹理)贴到 3D 模型的表面上的过程。通过定义每个顶点上的 UV 坐标,GPU 可以根据这些坐标从纹理中采样颜色,填充到模型表面的像素上。这可以让模型看起来有丰富的细节,而无需增加几何复杂度。

UV 坐标:范围通常是 (0,0) 到 (1,1),分别对应纹理的左下角和右上角。模型每个顶点都有对应的 UV 值,在渲染时插值得到每个像素的 UV,进而采样纹理。


25. 请问为什么延迟渲染路径能够优化有大量光源的场景渲染?

答案:

在前向渲染中,每个物体需要被每个影响它的光源渲染一次(Pass)。当光源和物体都很多时,复杂度是 O(物体数 × 光源数),非常巨大。

在延迟渲染中,首先通过 G-Buffer(几何缓冲区)将物体的几何信息(位置、法线、颜色、材质参数等)存储到多张纹理中。然后,在光照 Pass 中,对屏幕上的每个像素,根据其 G-Buffer 信息,计算所有光源对该像素的影响。其复杂度是 O(屏幕分辨率 × 光源数),与场景中的物体数量无关。因此,当光源数量很大时,延迟渲染的性能优势非常明显。


26. 法线贴图(Normal Map)为什么要使用切线空间(Tangent Space)?

答案:

切线空间是以模型表面顶点为原点、由 T(切线)、B(副切线/双切线)、N(法线) 三个互相垂直的轴构成的局部坐标系。

用切线空间的原因:

  1. 支持模型形变/动画:切线空间法线是"相对"于表面的,模型做骨骼动画、形变时,TBN 跟随顶点变化,法线方向依然正确。物体空间(Object Space)法线贴图在模型形变后就会出错。
  2. 贴图可复用:同一张切线空间法线贴图可以用在不同模型、不同部位,甚至对称复用(因为不绑定世界/物体方向)。
  3. 支持 UV 平铺和镜像:切线空间法线贴图能正确地在 UV 平铺、镜像贴图上工作。

外观特征:

  • 切线空间法线贴图整体偏蓝紫色(默认法线方向映射到 RGB 的 B 通道)。
  • 物体空间法线贴图颜色更五彩斑斓(各方向都有)。

原理:

渲染时,从法线贴图采样得到切线空间法线,再通过 TBN 矩阵把它从切线空间变换到世界空间(或视图空间),然后参与光照计算。


27. 什么是投影矩阵?透视投影和正交投影有什么区别?

答案:

投影矩阵是渲染管线顶点变换的一环,负责把视图空间(相机空间)的顶点变换到裁剪空间(Clip Space),之后再做透视除法得到 NDC(归一化设备坐标)。

完整变换链:模型空间 → 世界空间 → 视图空间 → 裁剪空间 → 屏幕空间。

投影 特点 参数 适用
透视投影(Perspective) 近大远小,有透视纵深,符合人眼 FOV、宽高比、近裁剪面、远裁剪面 3D 游戏主相机
正交投影(Orthographic) 无近大远小,平行投影,物体大小不随距离变化 左右、上下、近裁剪面、远裁剪面 2D 游戏、UI、小地图、编辑器视图

关键点:

  • 透视投影能营造空间纵深,但远距离物体会被压缩;正交投影尺寸稳定,但缺乏立体感。
  • 深度缓冲都是非线性的,近处精度高、远处精度低(透视投影尤其明显),这也是远平面太远会导致 Z-Fighting 的原因之一。

五、性能篇

1. 在 Unity 中哪些写法容易产生 GC Alloc?

答案:

GC Alloc 指的是托管堆上的内存分配,会触发垃圾回收,导致性能波动。常见的容易产生 GC Alloc 的写法有:

  1. 字符串操作

    • 使用 + 拼接字符串,尤其在循环中。应使用 StringBuilder。
    • 示例:string s = "a" + "b" + "c"; 会产生临时字符串。
  2. 装箱拆箱

    • 将值类型(如 int、float)传递给 object 参数的方法,如 Debug.Log(1)、string.Format("{0}", 1)。
    • 使用 ArrayList 存储值类型时也会发生装箱。
  3. foreach 循环遍历某些集合

    • 在旧版 Unity(2019.4 之前)中,遍历 ArrayList、List<T> 等会产生枚举器对象。新版对 List<T> 已优化,但遍历 Hashtable、Dictionary 的 KeyCollection / ValueCollection 仍可能产生分配。
  4. LINQ 查询

    • Where、Select、ToList() 等方法会创建临时对象和委托,产生 GC。
  5. GetComponents 返回数组

    • GetComponents<T>() 每次返回新数组。应使用 GetComponents<T>(List<T>) 重载传入预分配列表。
  6. 访问 UnityEngine.Object 的属性

    • 如 gameObject.name(每次返回新的 string,产生托管分配)等;注意 transform.position 返回的是值类型 Vector3,不产生 GC,但访问频繁时仍建议缓存到局部变量,避免重复的 C++ 跨界调用开销。
  7. 协程 yield return new WaitForSeconds

    • 频繁创建 WaitForSeconds 对象。应缓存常用的 WaitForSeconds 实例。
  8. 委托和匿名方法

    • 在循环中创建委托或 Lambda 表达式会生成闭包类对象。
  9. Enum.HasFlag

    • 内部会装箱,可改用位运算替代。
  10. Mathf 方法中传入值类型

    • 如 Mathf.Max(a, b) 本身无 GC,但若作为参数传入复杂表达式且结果装箱,则可能产生。

优化建议:

  • 使用 StringBuilder 拼接字符串。
  • 使用 List<T> 替代 ArrayList。
  • 使用 for 循环替代 foreach(在不确定时)。
  • 缓存常用 WaitForSeconds。
  • 使用对象池复用临时对象。

2. 在移动设备上运行项目帧率不稳,你会如何一步步定位和优化问题?

答案:

步骤 1:使用 Profiler 连接真机

  • 开启 Development Build 和 Autoconnect Profiler,在 Profiler 中查看 CPU Usage 的 Hierarchy 视图,找出每帧耗时最高的模块(如 Rendering、Scripts、Physics、GC),并观察帧率波动的瞬间哪个模块耗时最高。

步骤 2:分析渲染

  • 查看 GPU Profiler(如果支持),关注 DrawCall、SetPassCall、总顶点数、Overdraw 情况(可通过 Scene 视图的 Overdraw 模式查看)。
  • 检查是否使用了过多的透明物体、粒子系统或复杂的 Shader。
  • 如需更深层的 GPU 分析,可使用 RenderDoc 抓帧(渲染调试)或 Xcode Metal Profiler(iOS)。

步骤 3:分析逻辑代码

  • 在 Profiler 中查看脚本执行时间,找出耗时高的函数。
  • 检查 Update、LateUpdate、FixedUpdate 中是否有复杂的计算、Find、GetComponent 等操作。
  • 关注 GC 分配,使用 GC.Alloc 栏查看是否有频繁分配。
  • 注意是否有逻辑随时间增长:如 Update 中遍历的对象数量随游戏进程不断增多。

步骤 4:分析物理

  • 检查物理场景复杂度,刚体数量,是否在 FixedUpdate 中做了大量操作。
  • 查看 Physics 模块耗时。

步骤 5:分析内存

  • 检查是否有内存泄漏导致频繁 GC,或内存占用过高触发系统回收。
  • 使用 Memory Profiler 查看详细内存分布。

步骤 6:使用 Frame Debugger

  • 单帧分析渲染过程,查看 DrawCall 的细节,确认是否有多余的渲染。

步骤 7:使用原生工具

  • 若 Profiler 无法定位具体问题,使用 Xcode Instruments(iOS)或 Android Studio Profiler(Android)分析更底层的 CPU/GPU 瓶颈。

步骤 8:反复验证

  • 修改优化后,重新打包真机测试,对比帧率变化;若仍未解决,回到步骤 1 继续定位。同时排查资源配置:是否有大纹理、高精度模型导致加载和渲染压力。

3. 要做一个关卡加载与进战斗的预加载系统,目标是进入战斗后 0 卡顿。你会如何设计预加载?

答案:

目标: 将加载卡顿前置到加载界面,进入战斗时瞬间激活资源。

1. 异步加载所有战斗资源

  • 使用 Addressables.LoadAssetsAsync 或 AssetBundle.LoadAssetAsync 加载战斗所需的所有资源(模型、贴图、动画、音频、UI 预制体等)。
  • 在加载界面显示进度条,确保所有资源加载完成后再进入战斗。

2. 对象池预热

  • 根据战斗可能需要的最大数量,预先实例化所有需要动态生成的物体(如子弹、特效、敌人),并将它们失活放入对象池。
  • 实例化是开销较大的操作,必须提前完成。

3. 预热 Shader

  • 强制渲染所有用到的材质一次,使 Shader 的编译在加载阶段完成。
  • 可以创建一个隐藏相机,在加载场景中渲染一个包含所有材质的简单物体。

4. 资源依赖管理

  • 使用 Addressables 自动管理依赖,确保加载一个预制体时,它引用的贴图、材质等所有依赖都提前加载完毕。

5. 分帧加载

  • 即使使用了异步加载,实例化大量对象仍可能在单帧内造成卡顿。
  • 可以将实例化和初始化逻辑分摊到多帧中,利用协程或 UniTask 进行分帧处理。

6. 场景流式加载

  • 如果战斗场景很大,使用 LoadSceneMode.Additive 在后台异步加载场景,加载完成后再激活。

7. 内存管理

  • 确保在进入战斗前,卸载关卡加载场景中不再需要的资源,避免内存峰值。

4. 如何优化动画计算带来的 CPU 开销?

答案:

1. 降低骨骼数量

  • 合并骨骼,移除不必要的小骨骼。在模型导入设置中,启用 Optimize Game Objects 来移除不必要的 Transform 层级,减少骨骼更新开销。

2. LOD(Level of Detail)

  • 为动画模型设置 LOD Group,距离远时使用更低精度的模型和更少的动画层。

3. 动画压缩

  • 在模型导入设置中,选择合理的动画压缩格式(如 Optimal),可以显著降低动画数据的大小和 CPU 解压开销。

4. 减少更新频率

  • 对于不可见的角色,使用 Animator.cullingMode 设置为 BasedOnRenderers 或 CullCompletely,当模型被剔除时停止动画更新。
  • 对于远处的角色,可手动降低 Animator.updateMode 或通过脚本降低动画更新频率(如每两帧更新一次)。

5. 动画类型选择

  • Generic 动画比 Humanoid 动画性能更好,因为不需要进行 IK 和重定向计算。但 Humanoid 更通用。
  • 使用 Animator Controller 时,减少 Layer 和 StateMachine 的复杂度,避免使用 Any State 和复杂的条件判断。

6. 实例化优化

  • 对于大量相同的角色,可以使用 GPU Instancing 渲染,但动画更新仍在 CPU 进行。如果动画完全相同且可以同步,可考虑 Animation Instancing(如 Unity DOTS 动画或第三方插件)。

5. Texture 的内存怎么估算?比如一张 2048×2048 的 RGBA32 纹理大概占多少内存?

答案:

计算公式:

纹理内存占用(字节) = 宽度 × 高度 × 每个像素的字节数(BPP)

  • RGBA32:每个像素 32 位 = 4 字节(R、G、B、A 各 8 位)
  • 2048 × 2048 × 4 = 16,777,216 字节 = 16,384 KB = 16 MB

注意: 这是纹理在显存中解压后的内存占用。在磁盘上或内存中以压缩格式(如 ASTC、ETC2)存储时,占用会小很多(通常是原大小的 1/4 到 1/8)。另外,若启用了 Mipmap,还会额外增加约 33% 的内存。

其他格式示例:

  • RGB24:3 字节/像素 → 2048×2048×3 = 12 MB
  • RGBA16(16 位):2 字节/像素 → 8 MB
  • ASTC 6×6(每 6×6 像素块占 16 字节):约 2048×2048×16/(6×6) ≈ 1.86 MB

6. 纹理大小如何优化?

答案:

  1. 使用平台适配的压缩格式

    • Android:ASTC(推荐)、ETC2
    • iOS:ASTC、PVRTC
    • 压缩后内存可减少 4-8 倍。
  2. 调整最大尺寸

    • 根据纹理在屏幕上的实际大小,限制其最大尺寸。如 2048 的图在手机上可能只需 1024 或 512。
  3. 图集(Sprite Atlas)

    • 将多个小图合并到一张大图集中,减少 DrawCall,同时避免大量小纹理的开销。
  4. Mipmap

    • 对于 3D 场景中的纹理,启用 Mipmap 可以降低远处物体纹理的采样开销,但会增加约 33% 的内存。UI 纹理通常不需要 Mipmap,应禁用。
  5. 通道压缩

    • 如果纹理不需要 Alpha 通道,使用 RGB 格式;如果只需要 Alpha 通道,可以使用单独的 Alpha 8 纹理。
  6. 移除不必要的纹理

    • 使用工具(如 Texture Analyzer)检查项目中是否有未被引用的大纹理。
  7. 使用 Crunched 格式

    • 在压缩基础上进一步压缩,减小包体,但加载时会解压,适合不常加载的资源。

7. 模型内存怎么估算?音频内存怎么估算?

答案:

模型内存

模型内存 = 顶点数据内存 + 索引数据内存 + 骨骼数据 + 动画数据。

  • 顶点数据:顶点数 × 顶点结构大小(位置、法线、切线、UV 等,每个分量通常为 4 字节)。例如,一个顶点包含 Position (12 字节) + Normal (12 字节) + UV (8 字节) ≈ 32 字节。1 万个顶点约 320 KB。
  • 索引数据:三角形数量 × 3 × 索引大小(2 或 4 字节)。
  • 骨骼数据:每个骨骼有绑定信息,数量较少。
  • 模型内存主要存在于显存中,用于渲染。

音频内存

取决于加载类型:

  • Decompress On Load :加载时完全解压为 PCM 格式,内存占用 = 采样率 × 时长 × 声道数 × 位深/8。
    例如:3 分钟、44.1kHz、立体声、16 位的音乐,内存 ≈ 44,100 × 180 × 2 × 2 ≈ 31.7 MB。
  • Compressed In Memory:在内存中保持压缩状态(如 MP3、Vorbis),内存占用小,但播放时实时解压有 CPU 开销。
  • Streaming:流式加载,只缓存一小部分数据,内存占用极低(几十到几百 KB),适合长音乐。

8. 怎么区分内存占用和显存占用(什么东西存在内存中,什么东西存在显存中)?

答案:

存储位置 内容
内存(RAM) 代码和脚本(C# 代码、IL2CPP 生成的代码)、托管堆(new 出来的对象、数组、List)、原始资源数据(如磁盘上的 AssetBundle 数据,未解压的纹理数据,音频的压缩数据)、Unity 引擎内部数据。
显存(VRAM) 纹理(Texture,最终渲染时使用的格式)、模型网格数据(Mesh,顶点、索引缓冲区)、渲染缓冲区(RenderTexture、深度缓冲区、帧缓冲区)、Shader 程序和材质数据。

简单理解: CPU 处理的数据放在内存,GPU 处理的数据放在显存。资源从磁盘加载到内存,然后从内存上传到显存,之后 GPU 从显存读取数据进行渲染。


9. 对象(缓存)池是减少内存还是增加内存?

答案:

对象池增加内存(在稳态下)。

  • 没有对象池时,对象用完即销毁,内存会降下来。
  • 有对象池时,对象用完后放回池中,不会被 GC 回收,这些对象占用的内存会一直存在。

目的: 对象池是用 增加内存占用 来换取 降低 GC 频率 和 提高对象获取速度 的优化手段。它避免了频繁创建和销毁对象带来的 CPU 开销和 GC 导致的卡顿。


10. 图集是否更节省内存?

答案:

不一定。图集并不直接节省纹理数据本身的内存。

  • 如果图集使用不当:将多张小图合并成一张大图,如果图集利用率低(大量空白),内存占用反而会增加,因为必须为空白区域分配内存。
  • 图集的主要目的:减少 DrawCall。通过将多张纹理合并在一个图集中,多个 UI 元素可以使用同一个材质进行渲染,从而减少渲染批次。
  • 内存优化方面 :图集可以减少纹理的数量和切换开销,并且因为是一张大图,可以更好地利用压缩格式和 Mipmap,但总体内存取决于图集填充率。现代 Unity 的图集工具(如 Sprite Atlas V2)已能很好地优化图集布局。

11. 如何做一次上线前的内存预算与压测?

答案:

  1. 设定预算

    • 根据目标机型(低端机、中端机)设定内存上限,如 Android 低端机建议总内存不超过 600-800 MB(系统限制,Unity 应用能使用的内存通常不超过 1-1.5GB)。
  2. 基准测试

    • 使用 Profiler 的 Memory 模块,连接真机,记录游戏在典型场景(如主界面、战斗)下的内存占用。
    • 重点关注 Total System Memory 和 Unity Used Memory。
    • 细分内存类别:Assets(纹理、网格、动画)、GameObject、Managed Heap(托管堆)。
  3. 压力测试

    • 使用自动化测试或模拟极端情况:如长时间运行(1-2小时),测试是否有内存泄漏(内存持续增长)。
    • 快速切换场景,测试资源加载与卸载是否正常。
    • 在高画质、多特效场景下运行,测试内存峰值。
  4. 工具使用

    • Memory Profiler:查看详细的内存分配,分析哪些资源占用高,是否有资源重复加载。
    • AssetBundle 浏览器:检查 AB 包的依赖和大小。
  5. 分析与优化

    • 根据报告,找出内存占用最高的资源类型,进行针对性优化(如压缩纹理、降低网格精度)。
    • 检查托管堆内存,通过对象池等方式减少 GC 分配。
    • 确保场景切换时,未使用的资源被正确卸载(通过 Resources.UnloadUnusedAssets 或正确的 AB 包管理)。

12. 音频的加载类型中的 Decompress On Load(加载时解压)、Compressed In Memory(在内存中压缩)、Streaming(流式加载)有什么区别?

答案:

类型 说明 内存占用 CPU 开销 适用场景
Decompress On Load 加载时完全解压为 PCM 格式 最大 最低(无实时解压) 短促音效(枪声、脚步声)
Compressed In Memory 在内存中保持压缩状态,播放时实时解压 中等 中等(实时解压) 背景音乐、中等长度音效
Streaming 从磁盘/网络流式加载,只缓存小缓冲区 极低 较低(I/O 开销) 长背景音乐、语音

13. Unity 中在选择纹理压缩格式时 Compressed(压缩)和 Crunched(紧缩)格式的区别?

答案:

格式 特点 适用场景
Compressed 如 ASTC、ETC2、DXT,GPU 可直接采样,显存占用小,解压几乎无开销 常用资源,运行时需要快速访问
Crunched 基于 CPU 压缩算法(如 Crunched ETC),磁盘和内存(未解压时)占用极小,加载时先解压成普通压缩格式再上传显存,加载时间更长 不常加载的资源,如安装包中的纹理

14. 为什么 Unity 中使用了异步加载仍然可能卡顿?可能原因是什么?如何解决?

答案:

原因:

  1. 实例化开销 :Instantiate 是同步执行的,如果一次实例化大量对象或复杂预制体,会导致单帧耗时过长。
  2. 资源激活与初始化 :对象实例化后,会触发所有组件的 Awake 和 OnEnable,如果这些方法中包含大量逻辑,也会造成卡顿。
  3. 资源依赖的加载:异步加载可能只加载了根资源,其依赖的资源(如材质、贴图)可能还在异步加载中,实例化时触发这些依赖的初始化。
  4. Shader 编译:如果实例化时首次使用某个 Shader,会触发 Shader 的编译,这是一个非常耗时的操作。
  5. 大量回调同时触发:多个异步加载可能在同一时间完成,导致大量回调同时涌向主线程,瞬间涌入大量逻辑。
  6. 磁盘 I/O 或解压阻塞:资源加载本身可能因磁盘 I/O 或解压(如 Crunched 纹理、LZMA 压缩包)而导致短暂阻塞。
  7. 场景 Integration:实例化对象时需要将其加入到场景的层级结构中,可能触发场景的渲染数据结构更新(如光照图、遮挡剔除数据)。

解决方案:

  1. 对象池预热 :在加载场景中提前实例化好所需数量的对象放入池中,战斗时只需从池中取出(SetActive(true)),避免 Instantiate 开销。
  2. 分帧实例化 / 队列处理回调 :将需要实例化的对象分成多批,使用协程(yield return null)或 UniTask 分摊到多帧;对大量回调用队列缓冲,避免单帧处理过多任务。
  3. 延迟初始化 :将 Awake 或 Start 中的非关键逻辑延迟到第一次使用时执行(Lazy Initialization)。
  4. 预热 Shader :在加载阶段通过 Shader.WarmupAllShaders 或创建一个隐藏相机渲染所有材质来预热 Shader。
  5. 确保依赖资源已就绪 :使用 Addressables.LoadAssetsAsync 加载所有依赖,确保实例化时依赖都已加载完成;合理设置异步加载的优先级和顺序,避免资源竞争。

15. 如何优化异步加载后实例化对象带来的卡顿呢?

答案:

  1. 对象池预热 :在加载场景中提前实例化好所需数量的对象,放入池中,战斗时只需从池中取出(SetActive(true)),避免 Instantiate 开销。
  2. 分帧实例化 :将需要实例化的对象分成多批,使用协程(yield return null)或 UniTask 在多个帧中分别实例化。
  3. 延迟初始化 :将 Awake 或 Start 中的非关键逻辑延迟到第一次使用时执行(Lazy Initialization)。
  4. 预编译 Shader :在加载阶段通过 Shader.WarmupAllShaders 或创建一个隐藏相机渲染所有材质来预热 Shader。
  5. 资源预加载 :确保在实例化时,所有依赖资源都已完全加载并准备就绪,可以使用 Addressables.LoadAssetsAsync 加载所有依赖。

16. 缓存池(对象池)为什么可以优化性能?

答案:

对象池通过复用对象来优化性能,主要体现在:

  • 降低 GC 压力:避免频繁创建和销毁对象,从而减少垃圾回收的频率和耗时,防止因 GC 导致的卡顿。
  • 降低内存分配开销 :new 操作本身也有开销,尤其是在托管堆上分配内存。对象池将分配集中在一段时间内,减少了运行时的内存分配操作。
  • 降低初始化开销 :对象池中的对象通常已经完成了 Awake 等生命周期函数,从池中取出时只需重新设置状态,避免了 Instantiate 这种重量级操作。

代价: 对象池会增加内存占用(因为对象一直保留在内存中),但这是用内存换性能的典型做法。


17. Animator 分层动画,当挂载 Animator 组件的物体非常多时,会产生哪些性能问题?

答案:

  • CPU 开销大:每个 Animator 组件都需要单独计算动画状态机的逻辑,包括状态切换、参数计算、层的权重混合、IK 等。当数量达到成百上千时,CPU 开销会非常巨大。
  • 内存占用:每个 Animator 都会维护一份动画状态的拷贝,包括当前状态、参数等,会占用托管内存。
  • 更新不可控:即使动画模型被裁剪(Culling),Animator 组件可能仍然在后台更新状态机逻辑,造成不必要的开销。

优化:

  • 使用 LOD,降低远处角色的动画更新频率。
  • 设置 Animator.cullingMode 为 BasedOnRenderers 或 CullCompletely。
  • 考虑使用 Animation Instancing 技术,将多个相同角色的动画合并在 GPU 中处理。
  • 使用 Job System 和 Burst Compiler 自定义动画更新逻辑(DOTS 方式)。

18. Unity 中除了游戏场景上的对象可以使用对象池,还有哪些内容可以使用?

答案:

几乎所有需要频繁创建和销毁的对象都可以使用对象池:

  • 特效:子弹特效、爆炸特效。
  • UI 元素:聊天消息、滚动列表的 Item。
  • 音频源 :频繁播放的音效,可以复用 AudioSource 组件。
  • 网络消息:网络收发过程中频繁创建的消息对象。
  • 线程或任务 :Thread 或 Task 对象。
  • 大型数组 :例如 List<T>、Dictionary<T>,可以预先分配并清空复用。
  • 委托和回调:缓存的委托实例,避免重复创建。

19. 在手机移动设备上,如何优化 Unity 场景的顶点数?

答案:

  1. LOD:对场景中的主要模型设置 LOD Group,使远处物体使用低模,减少总顶点数。
  2. 视锥剔除(Frustum Culling)和遮挡剔除(Occlusion Culling):确保只有可见的物体才被提交渲染,这是最有效的减少顶点数的方法。
  3. 简化模型:在建模软件中优化模型,移除不必要的细节和边缘环。
  4. 合并网格:将场景中的小物件合并成少数几个大网格,虽然总顶点数不变,但可以减少 DrawCall 和顶点数据的上传次数。
  5. 使用 Impostor:对于极远处的物体,使用预先渲染的 Billboard 替代真实模型。

20. 如何在 Unity 当中避免内存泄漏的性能问题?

答案:

内存泄漏通常指托管内存泄漏(C# 对象)或原生内存泄漏(Native 资源)。

  1. 事件/委托订阅 :确保在 OnDisable 或 OnDestroy 中取消订阅所有事件和委托,防止对象因被事件引用而无法回收。
  2. 静态引用 :避免静态字段长期持有对 Unity 对象(GameObject、Texture)的引用。静态引用会使对象存活到应用程序结束。
  3. 资源卸载 :使用 Resources.UnloadUnusedAssets 或在正确的时机卸载 AssetBundle。使用 Addressables 可以自动管理。
  4. 协程 :停止不再需要的协程(StopCoroutine),特别是 while(true) 循环的协程。
  5. 清理对象池:对象池不是万能的,当切换场景时,如果池中的对象不再需要,应销毁池中的对象,而不是无限保留。
  6. 使用 WeakReference :对于缓存对象,如果允许被 GC 回收,可以使用 WeakReference。
  7. Profiler 检查:定期使用 Memory Profiler 检查内存占用,分析哪些对象数量异常增长,定位泄漏源。

21. 如何在 Unity 当中避免由于对象过多导致的性能问题?

答案:

  1. 对象池:复用对象,避免频繁创建和销毁。
  2. 分帧处理:当需要生成大量对象时(如子弹雨),不要在一帧内全部实例化,而是分多帧生成。
  3. 降低更新频率 :对于大量对象,如果不需要每帧更新,可以降低其 Update 的执行频率(如每隔几帧更新一次)。
  4. 使用 Job System 和 Burst:将大量相似的计算任务(如移动、AI 逻辑)放到多线程中并行处理,利用 Burst 编译器大幅提升性能。
  5. LOD 和剔除:减少渲染和逻辑计算的对象数量。
  6. 简化逻辑:简化每个对象的逻辑复杂度,例如使用状态机而不是每帧进行复杂判断。

22. Unity 中如何显示性能消耗?(编辑器中、真机中)

答案:

编辑器中:

  1. Profiler 窗口 (Window → Analysis → Profiler):实时查看 CPU、GPU、内存、渲染、物理等模块的消耗,可录制并分析帧数据。
  2. Frame Debugger (Window → Analysis → Frame Debugger):单帧分析渲染过程,查看每个 DrawCall 的细节。
  3. Stats 窗口 (Game 视图右上角 Stats):快速查看 DrawCall、顶点数、帧率等基本信息。
  4. Memory Profiler (Window → Analysis → Memory Profiler):分析内存占用细节,包括纹理、网格、托管堆等。

真机中:

  1. Profiler 连接真机 :开启 Development Build 和 Autoconnect Profiler,在编辑器中连接设备进行实时分析。
  2. Logcat / Xcode Console :查看 Android 的 adb logcat 或 iOS 的 Xcode 控制台输出,可输出 Debug.Log 和性能警告。
  3. Xcode Instruments (iOS) 或 Android Studio Profiler (Android):分析原生性能,如 CPU/GPU 占用、内存分配、帧率等。
  4. Unity 内置性能面板 :在 Development Build 中,可以通过 Application.SetStackTraceLogType 和自定义 UI 显示实时帧率、GC 次数等。
  5. 第三方工具 :如 RenderDoc (抓帧)、Unity Performance Testing Extension 等。

23. 请介绍一些在 Unity 中减少内存的方法。(至少说出 3 种方法)

答案:

  1. 纹理压缩:使用平台适配的压缩格式(ASTC、ETC2、PVRTC),并设置合适的最大尺寸和 Mipmap。
  2. 资源优化:降低纹理、网格、音频的精度和大小。例如,将 2048x2048 纹理降低到 1024x1024,使用更少的多边形模型。
  3. 使用图集:将多个小图合并成图集,减少纹理数量和 DrawCall,同时可以更好地利用压缩格式。
  4. 及时卸载 :卸载不需要的 AssetBundle,调用 Resources.UnloadUnusedAssets() 清理未使用的资源。
  5. 使用对象池:复用对象,减少托管堆的分配和释放。
  6. 使用简单数据类型 :对于频繁分配的小对象,使用 struct(值类型)替代 class(引用类型),减少堆内存分配。
  7. 压缩 AssetBundle:在打包时使用压缩(LZMA 或 LZ4),减小包体大小,但会增加加载时的解压开销。

24. 请介绍一些在 Unity 中提升性能的方法(至少说出 5 种方法)。

答案:

  1. DrawCall 优化:使用静态批处理、动态批处理、GPU Instancing、SRP Batcher,减少 CPU 渲染命令开销。
  2. 资源加载优化:使用异步加载(Addressables)、对象池预热、分帧加载,避免主线程卡顿。
  3. 脚本优化:减少 Update 中的操作,使用对象池,避免频繁的 GetComponent、Find,使用 Job System 和 Burst 进行并行计算。
  4. 渲染优化:启用遮挡剔除,使用 LOD,优化 Shader 复杂度,减少 Overdraw。
  5. 内存优化:使用纹理压缩,及时卸载无用资源,避免内存泄漏。
  6. 物理优化:简化碰撞体,减少刚体数量,降低 FixedUpdate 频率,使用层矩阵忽略不必要的碰撞。
  7. 动画优化:开启 Animator 的 Culling,降低动画采样率,合并骨骼。
  8. UI 优化:拆分 Canvas,避免使用 Mask,使用 TextMeshPro,禁用不可见 UI 的 RaycastTarget。

25. 为什么要为缓存池中的单个池子设置对象上限?

答案:

  • 防止内存泄漏:如果某个对象的使用频率极高,池子会无限增长,最终耗尽内存。设置上限可以防止这种情况。
  • 平衡内存与性能:设置一个合理上限,既能满足峰值需求,又能避免浪费内存。
  • 场景切换:当切换场景时,可以根据上限决定哪些池子应该被销毁或缩减,而不是无限保留所有对象。
  • 避免对象膨胀:对于某些临时对象,数量超过峰值后不再需要,设置上限可以及时释放多余对象。

26. Unity 中安卓主流的纹理压缩格式 ETC 和 ETC2 的主要区别是什么?

答案:

格式 特点 兼容性
ETC 只支持 RGB(不透明)纹理,不支持透明通道(Alpha)。压缩比高,兼容性好(几乎所有 Android 设备)。 Android 2.2+
ETC2 完全向后兼容 ETC。增加了对 RGBA(透明)纹理的支持,并改进了压缩质量。从 Android 4.3(API Level 18)开始广泛支持。 Android 4.3+

建议:对于现代 Android 设备,应优先使用 ETC2,因为支持透明纹理且压缩质量更好。对于需要兼容老设备的项目,可将透明纹理单独处理(如分离 Alpha 通道或使用其他格式)。


27. Android 平台常用压缩格式 ETC 不支持透明通道,那我们的半透明纹理应该如何处理?

答案:

  1. 使用 ETC2:如果目标设备 API Level ≥ 18,可以使用 ETC2 格式,它原生支持透明通道。
  2. 分离 Alpha 通道:将 RGB 和 Alpha 分别存储到两张纹理中。RGB 部分用 ETC 压缩,Alpha 部分用单独的格式(如 ETC2 的 Alpha 8 或 ATC 等)压缩,然后在 Shader 中合并。这样会多一次采样,但内存占用可能更低。
  3. 使用 RGBA 16bit 或 32bit:对于少量小尺寸的半透明纹理,可以放弃压缩,直接使用 RGBA 格式,但内存占用会大很多。
  4. 使用其他压缩格式:如 ASTC(支持透明通道),若设备支持(Android 5.0+ 大部分支持)。

28. Unity 中针对 IOS 平台我们使用的主流纹理压缩格式是什么?

答案:

主流是 ASTC (Adaptive Scalable Texture Compression) 和 PVRTC (PowerVR Texture Compression)。

  • ASTC:是更新的、更灵活的压缩格式,支持各种分辨率和位宽,压缩质量高,是 iOS 平台的首选(iOS 设备从 A8 芯片开始广泛支持)。
  • PVRTC:是 PowerVR GPU 的传统压缩格式,兼容性更好(支持所有 iOS 设备),但压缩质量相对 ASTC 稍差,且仅支持方形纹理(2 的幂次方)。

建议:新项目优先使用 ASTC,需要兼容旧设备时可混合使用 PVRTC。


29. Unity 中Instantiate 实例化游戏对象,主要消耗的是哪方面的性能,为什么

Instantiate 实例化 GameObject 主要消耗在 CPU 端。

  1. 需要复制预制体上全部组件,创建 C++ 原生对象以及 C# 托管脚本对象,带来内存分配,产生 GC 开销;
  2. Transform 层级更新、矩阵计算带来 CPU 开销;
  3. 会立刻执行 MonoBehaviour 的 Awake、OnEnable 生命周期函数,如果脚本内部逻辑复杂,会造成明显卡顿;
  4. Start 会延迟到下一帧执行。
    GPU 本身不是 Instantiate 直接消耗,实例出来的物体下一帧渲染才会带来渲染相关开销。
    频繁生成销毁就应该使用对象池,规避 Instantiate 和 Destroy 的性能开销。

30. 内存泄漏是什么,怎么排查和解决

内存泄漏(Memory Leak),简单来说就是:

对象已经没有业务上的使用价值,但程序仍然存在对它的引用或资源占用,导致内存无法正常释放,并且随着运行时间不断增长。

在 Unity 中要特别区分:

复制代码
C# Managed Memory
        ↓
      C# GC

Unity Native Memory
        ↓
Texture / Mesh / Material / AssetBundle 等原生资源

所以 Unity 的内存问题不一定都是 C# GC 问题。

面试中可以把排查思路总结成:发现内存持续增长 → 判断是 Managed 还是 Native → 找到是谁持有引用/资源 → 分析生命周期 → 修复引用或释放资源 → 再验证是否恢复。

面试口述总结

Unity 中的内存泄漏,本质是对象或者资源已经没有业务上的使用价值,但因为仍然存在引用或者资源系统引用,导致它无法正常释放。

排查时一般先通过 Unity Profiler 和 Memory Profiler 判断是 Managed Memory 还是 Native Memory,然后重复执行场景切换、打开关闭 UI 等操作,观察内存基线是否持续增长,再通过 Memory Profiler 对比快照和查看引用链,找到是谁还在持有对象。

常见问题包括静态变量、Singleton、事件和 Delegate 没有注销、Dictionary 或缓存无限增长、协程生命周期不正确,以及 Addressables、AssetBundle 等资源没有正确 Release。

解决的核心就是管理好对象生命周期,让注册和注销、加载和释放、创建和销毁形成对应关系。


六、热更新与 Lua 篇

1. Unity 中 AB 包和 Resources 资源加载区别是什么?

答案:

对比项 Resources 系统 AssetBundle
使用方式 Resources.Load 加载 Resources 文件夹下的资源 打包成独立包,运行时从本地或网络加载
包体 所有 Resources 资源打包到主包中 可分离,支持按需下载和热更新
内存管理 难以单独卸载,需 Resources.UnloadUnusedAssets 可精确控制加载和卸载
热更新 不支持 支持
依赖管理 自动处理 需手动管理依赖或使用 Addressables
适用场景 简单项目、少量资源 大型项目、需要热更新、内存可控

Addressables 是基于 AssetBundle 的现代化资源管理系统,提供了更易用的 API,推荐新项目使用。


2. 如果一个 AssetBundle 被多个 AB 包依赖,卸载依赖包时是否可以直接卸载它?如何处理相互引用的 AB 包?

答案:

不能直接卸载。AssetBundle 依赖管理基于引用计数。如果直接卸载被依赖的 AB 包,依赖它的 AB 包在加载其资源时就会找不到依赖,导致错误。

正确做法:

  • 手动引用计数 :当加载一个 AB 包 A 时,检查它的依赖列表,并对每个依赖包调用 AssetBundle.LoadAsset 或其加载方法,并手动增加引用计数。当 A 被卸载时,减少依赖包的引用计数,当引用计数为 0 时,才真正卸载依赖包。
  • 使用 Addressables :Addressables 系统自动管理所有依赖的引用计数,你只需关心资源本身,无需手动管理 AB 包的加载与卸载。这是推荐的做法。

3. Unity 使用 Lua 热更新方案时,进行垃圾回收时需要注意什么?

答案:

  • Lua 与 C# 的引用关系:如果 C# 对象被 Lua 引用(如通过 Lua 函数持有 C# 对象的委托),那么即使 C# 端没有引用,该对象也不会被 GC 回收,导致内存泄漏。需要及时解除 Lua 中的引用。
  • 频繁 GC:Lua 的 GC 是增量式的,但频繁创建大量临时 table 或 string 仍会触发较长时间的 GC,导致卡顿。应尽量复用 Lua 对象。
  • 在合适时机调用 GC :可以在场景切换等负载较低的时刻,手动调用 LuaGc() 来触发一次 GC,避免在战斗等关键帧中突然发生 GC。
  • 避免循环引用:Lua 中的 table 和 C# 中的对象之间要避免循环引用,否则 GC 难以正确回收。
  • 控制内存分配:在 Lua 中避免在热更新代码中创建大量临时表,尽量使用缓存和对象池。

4. 如果你要开发一个项目,Assetbundle 和 Addressables 你会如何选择?

答案:

方案 适用场景 理由
Addressables 新项目、大型项目、需要热更新、资源管理复杂 提供了更现代的 API,自动管理资源的依赖和引用计数,内置了异步加载和内存管理,简化了资源热更新流程,开发效率高。
AssetBundle 小型项目、需要深度定制资源管理系统、项目已大量使用 AB 包且迁移成本高 更底层,控制更精细,但需要自己实现引用计数、依赖管理、版本控制等,开发成本高。

推荐 :对于大多数新项目,建议使用 Addressables,因为它能大幅减少资源管理的复杂性和潜在的内存问题。


5. Unity 中 AssetBundle 的依赖管理应该如何正确处理?如何确保内存不泄漏?

答案:

依赖管理:

  • 打包 AssetBundle 时,Unity 会自动记录依赖关系,生成 Manifest 文件。
  • 加载一个 AssetBundle 前,必须先加载其所有依赖包(否则资源加载会失败或缺少依赖)。
  • 使用 AssetBundle.LoadFromFile 或 UnityWebRequest 加载主包后,通过 AssetBundleManifest.GetAllDependencies 获取依赖列表,逐个加载依赖包。
  • 引用计数:为每个 AssetBundle 维护一个引用计数,当引用计数为 0 时才能调用 Unload(true) 彻底卸载。

内存不泄漏做法:

  • 加载的资源(如 GameObject)在使用完后,应及时释放对它的引用,并调用 Resources.UnloadUnusedAssets 或依赖包卸载。
  • 使用 AssetBundle.Unload(false) 只卸载 AB 包本身,不卸载从中加载的资源(可能导致资源残留);Unload(true) 会同时卸载 AB 包和从中加载的资源,但如果有其他资源仍在使用,会导致丢失。
  • 推荐使用 Addressables,它内置了引用计数和自动卸载机制,极大简化了内存管理。

6. Lua与C#交互原理介绍

【游戏开发】Lua 与 C# 交互原理解析

Lua 与 C# 本质上是两个不同运行时之间的通信和调用桥接。

在 Unity 中使用 XLua、ToLua 等框架时,一般通过 Binding/Wrapper + Lua C API + Lua Stack 完成交互:

复制代码
C#
 ↓
Binding / Wrapper
 ↓
Lua C API
 ↓
Lua VM

反过来:

复制代码
Lua
 ↓
Lua VM
 ↓
Lua C API / Wrapper
 ↓
C#

其中最关键的几个概念是:

Lua VM、Lua C API、Lua Stack、userdata、Binding/Wrapper、类型转换。

面试口述总结

Lua 和 C# 的交互本质上是两个运行时之间的桥接。像 XLua、ToLua 这类框架一般通过 Lua C API、Binding/Wrapper 和 Lua Stack 来实现交互。

Lua 调 C# 时,会先通过 Wrapper 找到对应的 C# 对象和方法,再从 Lua Stack 获取参数并完成类型转换,调用 C# 后再把返回值交给 Lua;

C# 调 Lua 的过程则相反。Lua 中的 C# 对象通常通过 userdata 或 wrapper 来关联。

实际开发中还要重点关注跨语言调用、类型转换和 GC 带来的性能开销,所以一般会减少高频 Lua→C# 调用,把性能敏感的底层逻辑放在 C#,而把业务和热更新逻辑放在 Lua。


7. xLua性能优化

【游戏开发】xLua 性能优化技巧

xLua 性能优化的核心不是"让 Lua 本身跑得更快",而是:

减少 Lua 与 C# 之间的跨语言调用,减少类型转换和临时对象分配,同时降低 Lua GC 压力。

面试中可以把优化思路概括成:

复制代码
xLua 性能优化
    │
    ├── 减少 Lua ↔ C# 跨语言调用
    ├── 减少 GC / 临时对象
    ├── 减少反射和动态查找
    ├── 优化 Lua 热点代码
    └── 合理划分 Lua / C# 职责

其中最重要的是第一条:减少跨语言调用。

面试口述总结

xLua 性能优化一般从几个方面入手,最核心的是减少 Lua 和 C# 之间的跨语言调用,因为每次调用都会涉及 Wrapper、Lua Stack、参数和返回值转换。

对于高频逻辑,比如 Update,我会尽量减少 Lua→C# 的调用次数,能批量处理就批量处理。

其次是减少 Lua 和 C# 两边的临时对象创建,降低 GC 压力,同时缓存 Transform、Component 等频繁访问的对象。

绑定层则尽量使用 xLua 的代码生成和 GCOptimize,减少运行时反射和部分 GC。最后会通过 Unity Profiler 等工具定位真正的 CPU、GC 和跨语言调用热点,再针对性优化。


8. Lua 上值 UpValue是什么

Lua 上值(UpValue)

UpValue(上值)就是:闭包函数引用的、定义在它外部作用域中的变量。

例如:

lua 复制代码
function CreateCounter()
    local count = 0

    return function()
        count = count + 1
        return count
    end
end

这里内部匿名函数使用了外层的 count。

虽然 CreateCounter() 执行结束后,count 按普通局部变量来说应该离开作用域,但这个变量仍然被返回的闭包使用。这个 count 就是:UpValue

可以简单理解成:

复制代码
外层局部变量
    ↓
被内部函数引用
    ↓
变成闭包的 UpValue
    ↓
外层函数结束后依然存活

面试口述总结

Lua 的 UpValue 可以理解成闭包对外部局部变量的引用。

比如一个内部函数使用了外层函数中的 local 变量,那么这个变量就会成为这个闭包的 UpValue。这样即使外层函数执行结束,这个变量也不会立即消失,而是继续由闭包持有。多个闭包引用同一个外部变量时,还可以共享同一个 UpValue。

从 Lua 底层来看,外层函数执行期间 UpValue 可以指向栈上的变量,函数结束后则会转移到独立的存储区域继续保存。实际项目中需要注意大量创建闭包带来的 GC 开销,以及闭包长期持有大对象导致的内存无法及时释放。


9. 什么是闭包,有哪些隐患

闭包 = 函数 + 它所引用的外部变量。

简单来说,一个函数"记住了"它创建时所在的外部作用域,即使外层函数已经执行结束,它仍然可以访问外层变量。

例如:

lua 复制代码
function CreateCounter()
    local count = 0

    return function()
        count = count + 1
        return count
    end
end

local counter = CreateCounter()

print(counter()) -- 1
print(counter()) -- 2
print(counter()) -- 3

这里内部函数引用了外层的 count,因此 count 就是这个闭包的 UpValue。

可以理解成:

复制代码
函数
 +
它捕获的外部变量
 =
闭包

面试口述总结

闭包可以理解成函数和它所捕获的外部变量组成的一个整体,C# 主要通过编译器生成闭包对象来保存捕获变量,Lua 中主要通过 UpValue 保存这些外部变量,所以即使外层函数执行结束,闭包仍然可以访问这些变量。闭包常用于保存状态、回调和封装私有变量。

它的主要隐患有三个:第一,大量创建闭包会增加 Lua 的内存分配和 GC 压力;第二,闭包会持有 UpValue,如果捕获了大的 table 或对象,可能导致对象生命周期被延长;第三,在 Unity 尤其是 xLua 项目中,事件和 Delegate 持有闭包时,如果没有及时注销,容易形成跨语言引用链,导致内存无法及时释放。


七、网络篇

1. 服务器权威 和 客户端权威区别?如何防作弊?

答案:

客户端权威

  • 客户端拥有最终决定权,如移动、攻击判定等。
  • 优点:响应快,体验流畅。
  • 缺点:极易被外挂修改,不安全。
  • 适用:单机游戏、对延迟敏感的休闲游戏(需配合强大的反外挂系统)。

服务器权威

  • 服务器拥有最终决定权,客户端只发送操作请求,服务器验证并计算结果,返回给客户端。
  • 优点:安全性高,难以作弊。
  • 缺点:延迟敏感,需要做客户端预测和插值来优化体验。
  • 适用:绝大多数网络游戏(MMO、MOBA、FPS等)。

防作弊手段:

  • 核心逻辑(伤害计算、物品掉落、位置移动)放在服务器。
  • 对客户端发送的关键数据进行加密和校验。
  • 在服务器端进行反作弊检测,如检测非法移动速度、异常伤害值等。
  • 使用反外挂软件(如 BattlEye、EasyAntiCheat)扫描客户端内存和进程。
  • 对关键数据进行混淆和加固(混淆代码)。

2. 使用 UnityWebRequest 加载音效文件,应该使用什么 API?

答案:

使用 UnityWebRequestMultimedia.GetAudioClip。

csharp 复制代码
using UnityEngine;
using UnityEngine.Networking;

public IEnumerator LoadAudio(string url)
{
    using (UnityWebRequest request = UnityWebRequestMultimedia.GetAudioClip(url, AudioType.MPEG))
    {
        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            AudioClip clip = DownloadHandlerAudioClip.GetContent(request);
            // 使用 clip
        }
        else
        {
            Debug.LogError("加载失败: " + request.error);
        }
    }
}

AudioType 可选:MPEG、OGGVORBIS、WAV、AAC 等。


3. 在 Unity 中,Protobuf(pb)的介绍 / 使用流程 / 优点

Protobuf(Protocol Buffers)Google 推出语言无关、平台无关的二进制序列化协议 。游戏主要用于客户端与服务端 Socket 网络消息、本地存档数据 ,替代 JSON/XML。

Unity 常用两套库:

  1. Google.Protobuf:官方 C# 库,protoc 生成完整消息类;
  2. protobuf‑net:社区实现,Unity 项目更常用,IL2CPP 兼容性更好。

使用流程 :

①编写 proto 协议文件定义 message;

②protoc 工具批量生成 C# 实体类;

③导入 Unity 并引入 pb 运行库;

④对象序列化为 byte \[\] 用于 Socket 发送;收到字节数组再反序列化成对象;TCP 通信一般加 4 字节长度头处理粘包。

TCP 粘包 / 半包处理:TCP 是流式传输,发送端应在消息体前加长度头(如 4 字节);接收端先读长度头、再按长度读取完整消息体。拆包时可维护一个缓冲区,循环解析,直到缓冲不足则等待下一条数据到达。

✅优点(对比 JSON)

  1. 体积小:二进制编码,去掉 key 字符串,数据包体积远小于 JSON,节省流量、带宽。
  2. 序列化 / 反序列化速度快,CPU 开销低,适合游戏高频网络消息。
  3. 跨语言 :同一份.proto,可以生成 C#、C++、Go、Java、Python 代码,客户端服务端数据格式统一。
  4. 版本向前向后兼容 :新增字段不会破坏老版本解析;字段编号不能改、不能复用旧 id;老客户端自动忽略新增字段,实现协议平滑升级,不用强制整包更新。
  5. 强类型:预先生成 C# 实体类,编译期就能发现字段错误,JSON 是字符串运行时才报错。
  6. 支持嵌套 message、repeated 数组、枚举。

❌缺点

  1. 二进制格式,肉眼不可读,调试不方便;可以转 json 辅助查看。
  2. 需要写 proto 文件,执行工具生成 C# 代码,多一道流程。
  3. iOS IL2CPP 平台要注意库兼容性,部分动态 Emit 反射功能受限。

4. 游戏中如何实现断线重连机制?(断线重连的大概流程)

断线原因:网络抖动、切后台、wifi/4G 切换、服务端踢下线、socket 异常。

目标:无感知 / 少感知恢复游戏会话,不直接退回登录界面,恢复当前游戏状态。

核心思想: 断线重连 = 连接恢复 + 身份恢复 + 状态恢复。其中真正复杂的是最后的状态恢复。

整体流程

  1. 网络层监听 Socket 断开事件(底层 OnDisconnect),通过心跳包 + socket 回调检测网络是否真正断开。
  2. 区分断开类型:主动正常退出断开 VS 异常断线。主动退出不进入重连逻辑;异常断线启动重连状态机。
  3. 进入重连状态:弹出 UI 提示 "网络断开,正在重连...",冻结游戏输入,禁止玩家操作。
  4. 间隔时间重试连接(指数退避:1s、2s、4s,不要疯狂循环连,避免打满服务端),并限制最大重试次数,避免重连风暴。
  5. 建立 TCP 连接成功后,走重连握手流程,不是完整登录流程:携带会话凭证(sessionId / token,不要用账号密码)。服务端断线后不会立刻销毁玩家会话,会保留一段时间。
  6. 服务端校验 token/session 有效后,执行状态恢复 :
    • 携带客户端最后处理的消息 Sequence,让服务器补发断线期间遗漏的消息;
    • 若消息已超出服务器缓存范围,则下发完整状态快照(房间信息、战斗状态、角色数据);
    • 对重复消息和请求,通过 MessageId、Sequence 做去重和幂等处理。
  7. 客户端收到快照,恢复本地游戏状态,同步 UI、战斗、房间数据,关闭重连弹窗,解除输入冻结,回到游戏。
  8. 重连失败:达到最大重试次数,或 token 失效、会话超时销毁,提示网络异常,退回登录界面。
  9. 特殊场景:帧同步战斗需要请求历史战斗帧回放来恢复战斗。

面试口述总结

断线重连的核心不是简单重新建立 Socket,而是连接恢复之后的状态恢复 。通常先通过心跳包检测连接是否真正断开,区分主动退出和异常断线,异常断线进入重连流程,通过指数退避和最大重试次数重连,避免重连风暴。

连接建立后重新进行身份认证(携带 session token,不用账号密码),然后携带消息 Sequence 让服务器补发断线期间遗漏的消息,超出缓存则发完整状态快照,重复消息用 MessageId 做幂等去重。最终以服务器权威状态恢复客户端,完成同步后再恢复游戏操作。


  • 🎬 博客主页:https://xiaoy.blog.csdn.net

  • 🎥 本文由 呆呆敲代码的小Y 原创 🙉

  • 🎄 学习专栏推荐:Unity系统学习专栏

  • 🌲 游戏制作专栏推荐:游戏制作

  • 🌲Unity实战100例专栏推荐:Unity 实战100例 教程

  • 🏅 欢迎点赞 👍 收藏 ⭐留言 📝 如有错误敬请指正!

  • 📆 未来很长,值得我们全力奔赴更美好的生活✨

  • ------------------❤️分割线❤️-------------------------

资料白嫖,技术互助

学习路线指引(点击解锁) 知识定位 人群定位
🧡 Unity系统学习专栏 入门级 本专栏从Unity入门开始学习,快速达到Unity的入门水平
💛 Unity实战类项目 进阶级 计划制作Unity的 100个实战案例!助你进入Unity世界,争取做最全的Unity原创博客大全。
❤️ 游戏制作专栏 难度偏高 分享学习一些Unity成品的游戏Demo和其他语言的小游戏!
💚 游戏爱好者万人社区 互助/吹水 数万人游戏爱好者社区,聊天互助,白嫖奖品
💙 Unity100个实用技能 Unity查漏补缺 针对一些Unity中经常用到的一些小知识和技能进行学习介绍,核心目的就是让我们能够快速学习Unity的知识以达到查漏补缺
相关推荐
意图共鸣1 小时前
意图共鸣科技 · 时间胶囊(五)|认知中间层:设计思路与《认知律》
人工智能
深蓝AI4 小时前
10 万星投给了记忆:claude-mem 把编码 Agent 的断片压进 SQLite,值不值得装
人工智能·程序员
limuyang28 小时前
我在 2核2G 云主机上跑 OpenViking:一次关于云主机、模型选型与长期成本的真实复盘
人工智能·开源·ai编程
Shulex10 小时前
跨境电商 AI Agent 核心架构解析:从只读问答到自主执行的工程落地
人工智能·架构·智能客服·跨境电商
Java后端的Ai之路10 小时前
CAS并发控制-从CPU指令到企业级乐观锁
vue.js·人工智能·python·cas·乐观锁
情 九10 小时前
MCP 实战 06:把 HTTP 接口接成工具,让 AI 帮你查数据
人工智能·网络协议·http·mcp
码农小旋风10 小时前
Ray分布式计算引擎介绍
人工智能·架构
火山引擎和TA的超级拍档们10 小时前
产品情报局 08 | AI 内容助手:全网热点驱动,一站式打通内容生产全链路
人工智能