一、Unity 编辑器版本选择
1. 为什么面试会问 Unity 使用哪个版本?
面试官询问 Unity 版本,主要是想考察以下方面:
- 是否真实使用过 Unity 开发项目
- 是否了解商业项目中的版本管理意识
- 是否知道版本升级的风险
实际项目不会随意升级 Unity,因为可能影响:
- AssetBundle 资源
- Shader
- Package Manager
- 第三方插件
- Android/iOS 打包环境
- IL2CPP 配置
所以项目通常优先选择稳定版本,而不是最新版本。
2. Unity 版本类型
Tech Stream
特点:
- 新功能更新快
- 可以体验最新特性
- 适合实验、研究
缺点:
- 稳定性相对不足
- 插件兼容风险较高
LTS(Long Term Support)
长期支持版本。
例如:
- Unity 2021.3 LTS
- Unity 2022.3 LTS
特点:
- 稳定
- Bug 修复周期长
- 商业项目常用
- 插件和平台支持成熟
二、Unity 项目模板区别
Unity Hub 创建项目时常见模板分为核心模板、示例模板、学习模板。
1. 核心模板(Core Template)
用途:正式开发项目。
特点:
- 基础、干净
- 不包含大量示例资源
- 方便自己配置项目环境
例如:
- 2D Core
- 3D Core
商业项目通常从 Core 模板开始,然后根据需求添加:
- UGUI
- Cinemachine
- Addressables
- 第三方插件
2. 示例模板(Sample Template)
用途:学习和展示 Unity 技术效果。
包含:
- 示例场景
- 模型资源
- Shader
- 特效
- 高质量材质
适合:
- 查看 Unity 能实现的效果
- 学习渲染技术
不适合作为正式项目起点,因为内容复杂,资源较多。
3. 学习模板(Learning Template)
用途:学习 Unity 工作流。
特点:
- 包含教程内容
- 有示例代码
- 适合新手学习系统
例如:
- FPS Microgame
- 2D Game Kit
三、Unity 渲染管线
1. 什么是渲染管线?
渲染管线(Rendering Pipeline)是 Unity 将场景中的:
- 模型
- 材质
- 灯光
- Shader
转换成屏幕像素的过程。
流程:
场景物体 → 材质 → 光照计算 → Shader → GPU绘制 → 屏幕显示
2. Built-in Render Pipeline、URP、HDRP区别
1. Built-in Render Pipeline(内置渲染管线)
Unity 最传统的渲染管线。
优点:
- 成熟稳定
- 兼容性最好
- 学习资料最多
缺点:
- 对现代渲染优化能力有限
- 自定义渲染流程能力较弱
适合:
- 老项目
- 小型项目
- 教学项目
2. URP(Universal Render Pipeline)
通用渲染管线。
目标:
在性能和画质之间取得平衡。
适合:
- 手机游戏
- PC游戏
- VR
- 跨平台项目
优点:
- 性能优化更好
- 支持 Shader Graph
- 支持 SRP Batcher
- 支持现代后处理效果
缺点:
- 部分 Built-in Shader 不兼容
- 老插件可能需要适配
手游项目目前大量使用 URP。
3. HDRP(High Definition Render Pipeline)
高清渲染管线。
目标:最高画质。
适合:
- 高性能 PC
- 主机
- 3A 游戏
特点:
- 高质量光照
- 高级材质
- 实时光追
- 高级后处理
缺点:
- 性能消耗高
- 不适合移动平台
三种管线总结
管线 | 特点 | 常见场景 |
Built-in | 成熟稳定、兼容性最好 | 老项目、小项目 |
URP | 性能和画质平衡 | 手游、跨平台 |
HDRP | 最高画质 | PC、主机、3A |
四、游戏循环、帧率与 Unity Update
1. 游戏循环(Game Loop)
游戏运行时,本质上是在不断重复执行一个循环:
这个循环每执行一次,就产生一帧画面。
一帧包含了:
- 游戏状态更新
- 逻辑计算
- 画面绘制
因此:
FPS表示一秒内游戏循环执行多少次。
2. 什么是帧率(FPS)?
FPS(Frames Per Second)表示:
每秒生成和显示多少帧画面。
例如:
- 30 FPS:1秒生成30帧
- 60 FPS:1秒生成60帧
- 120 FPS:1秒生成120帧
帧率越高,单位时间内画面变化越细,运动表现越流畅。
3. 什么是帧时间(Frame Time)?
帧时间表示:
完成一帧所需要的时间。
帧时间也叫Time.deltaTime, 表示上一帧到当前帧之间经过的时间,单位是秒。
例如:
60 FPS:
所以:
换成 Unity:
注意:
Unity 使用的是秒,不是毫秒。
所以:
同理:
FPS | 每帧时间 |
30 FPS | 33.3ms |
60 FPS | 16.7ms |
120 FPS | 8.3ms |
FPS越高,帧间隔越短,画面更新越频繁。
示例:打印出游戏帧率
4. 30 FPS、60 FPS、120 FPS区别
30 FPS
每帧约33.3ms。
特点:
- GPU压力较低
- 功耗较低
- 动作变化不够细腻
常见于:
- 剧情游戏
- 对画质要求较高但性能有限的场景
60 FPS
每帧约16.7ms。
特点:
- 流畅度和性能平衡较好
- 大多数游戏常用目标
120 FPS
每帧约8.3ms。
特点:
- 动作更加平滑
- 输入延迟更低
- 快速移动更清晰
缺点:
- CPU/GPU压力增加
- 功耗提高
演示动画
5. Unity Update 与帧率的关系
Unity中的 Update:默认每帧执行一次。
注意:Update没有固定时间间隔,执行频率取决于当前游戏帧率。
例如:
60 FPS:
30 FPS:
120 FPS:
因此更准确的说法是:
Update每帧调用一次,但每帧持续时间不固定。
6. Time.deltaTime和 speed 的关系
错误写法:
表示:每帧移动speed。
问题:
60 FPS设备:
120 FPS设备:
同样代码,不同设备速度不同。
正确写法:
表示:根据这一帧实际经过的时间 计算移动距离。
这样可以保证不同帧率下运动速度一致。
7. Time.deltaTime 的影响因素
Time.deltaTime 是由什么决定的?
Time.deltaTime 不是由某个固定设备参数决定,也不是只由 GPU 决定。它取决于:
一帧执行过程包括:
因此:
- CPU计算过多,会导致帧时间增加
- GPU渲染压力过大,会导致帧时间增加
- 场景复杂度提高,也会导致帧时间增加
Time.deltaTime 与设备性能的关系
同一个 Unity 项目:
高性能设备:
低性能设备:
例如:
8. 1 / Time.deltaTime 的含义
公式:
表示:
根据当前这一帧耗时,估算如果保持该耗时,1秒可以运行多少帧。
例如:
得到:
它是一个瞬时 FPS 估计值,不是统计出来的平均 FPS。
区别:
平均 FPS:
表示一段时间内平均运行多少帧。
瞬时 FPS:
表示当前帧时间对应的 FPS 估计。
五、Unity Legacy Input System(旧输入系统)
Unity 旧输入系统主要通过三个 API 获取玩家输入:
Input.GetKey():检测具体按键状态
Input.GetButton():检测游戏功能按钮
Input.GetAxis():获取连续输入值
三者区别:
1. Input.GetKey:检测具体按键
表示:
检测某个具体按键是否处于按下状态。
GetKey
按住期间,每一帧返回
true。例如:
适合:
- 持续移动
- 持续攻击
- 蓄力
例如:
GetKeyDown
表示:
只在按下的第一帧返回 true。
例如:
适合一次性事件:
- 跳跃
- 开枪
- 打开背包
GetKeyUp
表示:
只在按键从按下变为释放的第一帧返回 true。
例如:
适合:
- 释放技能
- 蓄力攻击结束
2. Input.GetButton:输入功能映射
与
GetKey 最大区别:GetKey:我关心哪个键。
例如:
GetButton:我关心哪个游戏功能。
例如:
Input Manager 中可以配置:
代码:
无论玩家使用:
- 键盘 Space
- 手柄按钮
都会触发跳跃。
3. Input.GetAxis:获取连续输入
与前两个不同,它返回的是
float:例如:
常用于:
- 角色移动
- 摄像机旋转
- 摇杆控制
例如:
4. Joystick Button 是什么?
Joystick Button 指游戏手柄按钮。
例如:
在 Unity 旧输入系统中通常表示为:
它可以绑定到游戏功能,例如:
实现键盘和手柄共同控制。
5. GetAxis 和 GetAxisRaw 区别
GetAxis
带平滑处理。
输入变化:
适合:
- 角色移动
- 摄像机移动
- 赛车方向控制
GetAxisRaw
无平滑,直接返回输入值。
例如:
适合:
- UI控制
- 需要立即响应的操作
- 格斗游戏输入
示例:
三个 API 总结
API | 返回值 | 关注内容 | 使用场景 |
GetKey | bool | 具体按键 | WASD、Space |
GetButton | bool | 游戏功能 | Jump、Fire |
GetAxis | float | 输入方向和强度 | 移动、摇杆 |
参考 Unity keycode:
六、Unity 角色移动、速度与 Rigidbody2D 物理运动
玩家按下按键后,角色为什么会移动、速度为什么会受重力和碰撞影响,以及什么情况下该用 Rigidbody2D。
本节沿用第五点的 Legacy Input System 示例。新版 Input System 的输入 API 不同,但“读取输入 → 形成运动意图 → 在物理步中驱动刚体”的思路相同。
版本提示:本笔记前面的代码以 Unity 2022 LTS 常见的Rigidbody2D.velocity为例。Unity 6 的二维刚体线速度成员名为linearVelocity。它们表达的是同一个“二维线速度”概念;写代码时请以项目实际 Unity 版本的 API 为准,不要混用成员名。
1. 从输入到角色移动的完整流程
角色移动不是“按键直接改位置”,而是一个连续链路:
其中:
Input.GetAxisRaw("Horizontal"):读取输入方向。键盘通常得到-1 / 0 / 1;手柄轴可能得到中间值。
xInput:玩家这一帧想向左、静止还是向右的“意图”,不是位置。
moveSpeed:设计上的移动速度参数。
Rigidbody2D:让物体进入 Unity 2D 物理模拟。
velocity:刚体当前的速度,单位是世界单位/秒,不是“每一帧移动距离”。
2. 推荐的基础写法:Update 采样,FixedUpdate 应用物理
这里的职责划分很重要:
FixedUpdate 的次数与 Update 不一一对应:低帧率时一帧渲染之间可能执行多次物理步,高帧率时也可能隔若干渲染帧才执行一次物理步。因此不能简单把 GetButtonDown 放到 FixedUpdate;否则短暂按键可能恰好发生在两次物理步之间而被漏掉。3. moveSpeed 到底表示什么
若键盘输入为:
xInput | moveSpeed | 目标 X 速度 | 含义 |
-1 | 5 | -5 | 向左,每秒 5 个世界单位 |
0 | 5 | 0 | 停止施加水平移动意图 |
1 | 5 | 5 | 向右,每秒 5 个世界单位 |
所以
moveSpeed 的正负会影响方向,绝对值决定速度大小。通常把它设为正数,方向交给 xInput 控制。常见错误:
Time.deltaTime 用于“直接计算位移”,例如 transform.position += direction * speed * Time.deltaTime。而 velocity 表示的是单位时间内的速度,直接赋 5 就已经表示“每秒 5 个世界单位”。4. Rigidbody2D.velocity 与 Vector2:为什么要保留另一个分量
Rigidbody2D.velocity 的类型是 Vector2:例如:
速度 | 运动含义 |
(5, 0) | 向右移动 |
(-5, 0) | 向左移动 |
(5, 10) | 向右上方运动 |
(5, -10) | 向右下方运动 |
水平移动只应改 X:
跳跃只应改 Y:
每次赋值其实都在写入一个完整的 Vector2,并不是两个赋值会自动合并。之所以不会互相“搞没”,是因为我们主动读取并带回不想改的那一项:
反例:
也不要写
rb.velocity.x = ...。velocity 返回的是一个 Vector2 值;正确做法是“读出 → 修改分量 → 整体写回”,正如上面的 Vector2 velocity = rb.velocity 示例。Vector2 是 struct 值类型。这里的 new Vector2(x, y) 表示构造一个新的二维数值,不应把它简单等同于 new GameObject()、new List<T>() 等引用类型对象分配。把速度设置封装进 SetVelocity(x, y) 后,方法内部仍然需要构造并写回一个完整的 Vector2;因此选择方法封装还是直接赋值,主要依据职责和可维护性,不是为了少写一次 new。5. 速度还会受到哪些物理因素影响
角色最终运动不是只由输入决定:
因素 | 作用 | 要点 |
Gravity Scale | 持续改变 Y 方向速度 | 跳起后 Y 速度会逐步减小、到 0、再为负值,形成上升和下落。 |
Linear Damping / Drag | 让速度逐步衰减 | Unity 6 称为 Linear Damping;旧版本 Inspector 常称 Drag。 |
碰撞 | 接触墙、地面后产生碰撞响应 | Collider 只定义形状;实际反应还取决于 Rigidbody2D、材质和碰撞配置。 |
摩擦与弹性 | 影响沿表面的滑动和反弹 | 通常由 Physics Material 2D 配合 Collider 设置。 |
AddForce | 施加持续力或瞬时冲量 | 更符合“推、撞、爆炸、后坐力”等物理感需求。 |
Mass | 影响受力后的加速度与碰撞表现 | 不会改变手动直接赋值的 velocity,但会影响 AddForce 的效果。 |
一个很实用的判断是:
- 每个
FixedUpdate都直接写入velocity.x:角色横向控制会很“跟手”,但摩擦或外力试图改变 X 速度时,下一物理步可能又被你的代码覆盖。
- 用
AddForce:更遵从质量、阻力和外力,手感更有惯性,但不如直接设置速度稳定。
- 不要让多个脚本在同一个物理步同时设置同一刚体的
velocity;最后写入的一方会覆盖前者的意图。
6. 角色移动的四种常见方式
方式 | 本质 | 适合场景 | 主要注意点 |
修改 Transform | 直接改位置 | 不参与物理的展示物体、简单非物理移动 | 动态 Rigidbody2D 不应靠它移动,否则会绕开物理模拟并可能造成穿透或抖动。 |
设置 Rigidbody2D.velocity | 直接指定目标速度 | 平台跳跃角色、需要即时停走的控制 | 速度已有“每秒”含义,不再乘 deltaTime;要保留不想修改的分量。 |
Rigidbody2D.AddForce | 施加力或冲量 | 推力、爆炸、击退、真实惯性 | 质量、阻尼和 ForceMode2D 会影响结果。 |
Rigidbody2D.MovePosition | 在下一物理步请求刚体移动到目标位置 | 需要走 Rigidbody2D 流程的受控位移 | 在 FixedUpdate 调用,并结合刚体 Body Type 与碰撞方案设计。 |
选择原则不是“哪个 API 更高级”,而是这个对象是否需要由物理系统负责。同一对象的同一段运动不要同时让 Transform、velocity 和 AddForce 三套逻辑各自决定。
7. 本节常见错误清单
- 把
Rigidbody2D.velocity乘Time.deltaTime,把“速度”误当成“位移”。
- 用
new Vector2(x, 0)控制水平移动,意外清空跳跃和下落的 Y 速度。
- 动态刚体用
transform.position或Translate直接移动,导致物理响应异常。
- 把
GetButtonDown放到FixedUpdate,偶发漏跳。
- 忘记做落地检测,让角色可以在空中无限跳。
- 同一个刚体被多个脚本反复写速度,排查时看起来像“物理失控”。
8. velocity 是持续状态,不是单帧移动命令
这不是“让刚体只在当前帧移动一次”,而是把刚体当前线速度设为
(5, 12)。之后 Y 会受重力影响;如果没有脚本、阻尼、摩擦、碰撞冲量或其他外力修改 X,velocity.x 可以继续保持为 5。因此停止输入不等于刚体自动停止。只有后续代码把目标水平速度写成 0,或者物理系统通过阻尼、摩擦等机制改变速度,刚体才会减速或停止。
9. 输入意图、逻辑状态与物理速度
数据 | 表示什么 | 示例 |
xInput | 玩家当前想向左、静止还是向右 | -1、0、1 |
currentState | 当前允许执行哪组逻辑 | Idle、Walk、Jump、Air |
rb.velocity | 刚体当前真实运动状态 | (5, -3) |
三者不会自动同步。例如
xInput = 0 只表示玩家已经松开方向键,刚体仍可能保留 velocity.x = 5。输入是意图,状态是规则,Rigidbody2D 是物理结果;它们之间必须由代码显式转换。10. Linear Damping、摩擦力与显式速度控制
方式 | 本质 | 适合解决的问题 |
Linear Damping / 旧版 Drag | 让整个线速度逐渐衰减 | 整体阻尼和惯性手感 |
Physics Material 2D Friction | 通过接触面的切向摩擦减速 | 不同表面和物理对象的接触表现 |
显式设置 velocity | 控制代码直接指定当前目标速度 | 需要即时停走、结果可预测的角色控制 |
如果角色落地后因旧的
velocity.x 继续滑动,首先要检查哪个状态负责更新或清除水平速度。用 Drag 让问题看起来减轻,并没有补上缺失的状态规则,而且会同时改变跳跃、下落、冲刺和击退的速度衰减。11. 用方法封装 Rigidbody2D 速度写入
统一方法让调用方只表达目标速度,拥有 Rigidbody2D 的对象负责真正写入。以后增加限速、状态保护或调试记录时,可以集中修改一个入口。
但封装方法可能带有额外行为。例如某些项目的 SetVelocity 还会根据 X 速度调用翻转逻辑。调用者必须知道这个副作用;当击退方向、移动方向和视觉朝向不再完全一致时,应考虑把“设置速度”和“更新朝向”拆开。
七、C# 字段声明与访问权限
第七点只讨论 C# 中“数据怎么定义、谁能访问它”。
\[SerializeField\]、Inspector 和 Unity 保存数据的机制放到第八点单独解释。1. 什么是字段(Field)
字段是定义在类内部、方法外部的数据。它属于对象,用来保存该对象持续存在的状态或配置。
这里:
部分 | 含义 |
private | 访问权限 |
float / int | 数据类型 |
moveSpeed / currentHp | 字段名 |
= 5f / = 100 | 创建组件实例时使用的默认初始值 |
每个挂有
PlayerController 的 GameObject 都有自己的实例字段。例如两个敌人可以分别拥有不同的 currentHp,互不共享。2. 字段、局部变量与属性的区别
概念 | 声明位置 | 生命周期/用途 |
字段 Field | 类中、方法外 | 保存对象状态或配置;多次方法调用之间仍可使用。 |
局部变量 Local Variable | 方法或代码块内 | 只服务于当前一次执行,适合临时计算。 |
属性 Property | 类中、带 get/set | 为外部读取或修改字段建立受控入口。 |
自动属性可以只公开读取权限:
它大致可以理解为由编译器生成一个隐藏的后备字段,再通过属性控制访问:
public get 允许外部读取,private set 表示只有声明该属性的类能修改。自动属性并不是“不保存数据”,只是后备字段由编译器生成。例如
xInput 只在 Update 中用一次,可以是局部变量;而“当前生命值”“移动速度”“当前目标”等需要跨方法保存的数据,通常是字段。3. public、private、protected
修饰符 | 谁可以直接访问 | 适用场景 |
public | 任何能拿到该对象引用的代码 | 真正需要对外公开的接口或数据。 |
private | 当前类内部 | 默认选择:内部状态、内部实现细节。 |
protected | 当前类与继承它的子类 | 基类故意留给子类扩展的状态或行为。 |
public 和 private 解决的是C# 访问权限,不是“要不要显示在 Inspector”的唯一开关;Unity 的序列化和 Inspector 规则在第八点处理。4. 为什么字段通常优先 private
对象自己的内部状态应该由对象自己维护。若任何脚本都能随意写:
那么排查“谁把数据改坏了”会非常困难。
常见做法是字段保持
private,再按需要提供受控方法或只读属性:这样外部不能跳过规则直接篡改
currentHp,而是必须走 TakeDamage 的校验逻辑。这就是封装:数据与维护数据的规则放在同一个对象中。当外部需要读取状态,但修改必须由对象自己完成时,可以使用:
它比
public float facingDir 更能限制错误写入。外部状态可以用 FacingDir 计算冲刺方向,却不能绕过角色自己的 Flip 规则直接改变朝向。5. 实例字段与 static 字段
static 字段属于“类”,不是某一个 GameObject。适合全局计数、静态配置等;不适合保存每个角色各自的血量、速度或 Inspector 配置。不要因为想“全局都能访问”就滥用 static。6. 字段声明的常见错误
- 把需要持续保存的数据写成局部变量,导致每次进入方法都重新初始化。
- 仅为了让 Inspector 可见就把所有字段写成
public,破坏封装。
- 以为字段默认值一定会覆盖场景中已有对象的值;带序列化时,Inspector 保存值可能优先,见第八点。
- 用一个
static字段存所有角色的独立状态,造成多个对象互相影响。
- 名称不表达含义。普通私有字段通常使用
camelCase,例如moveSpeed、currentHp。
八、Unity 字段序列化与 Inspector 特性
这一点解释三个必须分开的层级:
1. 什么是 Unity 序列化
序列化可以理解为:Unity 将对象字段的状态转换为可保存的数据,并在之后重新恢复。
这就是 Inspector 能显示和编辑脚本字段的基础。Inspector 改动的是 Unity 保存的字段数据,不是“只改代码里的文本默认值”。
例如:
第一次给对象添加组件时,Inspector 通常显示
5。后来若在 Inspector 改为 8 并保存,场景或 Prefab 保存的是 8。此后即使把代码改成 = 10f,已经存在的对象仍可能继续使用保存的 8。2. [SerializeField] 的原理与作用
这句的真实含义是:
private仍然是 private:其他脚本不能写player.moveSpeed。
\[SerializeField\]告诉 Unity:即使该字段不是 public,也要按 Unity 的字段序列化规则保存并显示它。
- 因此它适合“需要在 Inspector 配置,但不想成为对外公开接口”的数据。
它不会:
- 把 private 变成 public。
- 自动做范围校验。
- 把字段变成只读或线程安全。
- 让任何 C# 属性自动被 Inspector 序列化。
Unity 的常规字段序列化要求至少满足:
- 字段是
public,或标记了\[SerializeField\]。
- 不是
static、const、readonly。
- 字段类型可由 Unity 序列化,例如基础类型、enum、
Vector2/Vector3、UnityEngine.Object引用、可序列化数组/List,以及标记\[System.Serializable\]的自定义类型。
Unity 通常针对字段工作,不是普通 C# 属性。不要以为写了
public float Speed \{ get; set; \} 就会和字段一样出现在 Inspector。例如下面的自动属性默认不会因为
public 就像 public 字段一样显示:如果确实需要让 Unity 序列化编译器生成的后备字段,可以把特性明确应用到 field:
但朝向、当前状态、计时器等运行时数据通常由代码维护,不一定需要暴露给 Inspector。
public 解决外部代码能否访问,[SerializeField] 解决 Unity 是否保存字段并在 Inspector 中显示,两者不能混为一谈。3. 为什么项目里常用 [SerializeField] private
这种写法同时满足:
典型适用对象:
- 数值配置:移动速度、伤害、冷却、检测距离。
- 外部引用:目标 Transform、UI、音效、Prefab。
- 资源引用:AudioClip、Animator、ScriptableObject。
若字段只是运行时缓存,例如“通过
GetComponent 找到的自身 Rigidbody2D”,通常不必拖到 Inspector,也不必序列化:4. 常用 Inspector 特性
特性 | 作用 | 示例 | 常见误解 |
[Header] | 给后续字段添加标题分组 | \[Header("Movement")\] | 只影响 Inspector 排版,不影响访问权限和逻辑。 |
[Tooltip] | 鼠标悬停时显示说明 | \[Tooltip("角色水平移动速度")\] | 不会验证数值正确性。 |
[Range] | 在 Inspector 显示滑杆 | \[Range(0f, 10f)\] | 主要是编辑器 UI;运行时代码仍可写出范围外数值。 |
[Space] | 在字段之间留出视觉间距 | \[Space(12)\] | 只改变排版。 |
[HideInInspector] | 隐藏字段在 Inspector 中的显示 | \[HideInInspector\] public int debugId; | 不会把 public 变 private;序列化规则也不会因此自动改变。 |
组合示例:
如果数值必须始终合法,不能只依赖
\[Range\];还应在运行逻辑中校验或夹紧,例如 Mathf.Clamp,必要时在 OnValidate 中做编辑器期检查。5. Inspector 值、代码默认值与常见问题
现象 | 原因 | 处理思路 |
改了 = 5f,场景中的对象还是旧值 | 该实例已有 Inspector 序列化值 | 检查场景对象和 Prefab Override,按需求重新设置或重置组件。 |
字段没有出现在 Inspector | 不满足序列化条件,或被隐藏 | 检查访问修饰符、 \[SerializeField\]、类型、static/readonly 等限制。 |
拖入引用后运行时报空 | 引用没有在当前对象/Prefab 实例上正确保存,或目标运行时被销毁 | 在 Awake/OnValidate 做必要检查,并确认 Prefab Override。 |
重命名字段后旧配置丢失 | 序列化数据按字段名关联 | 重构已发布数据时,学习并使用 \[FormerlySerializedAs\] 做迁移。 |
本节结论:
private 管“谁能从代码访问”;\[SerializeField\] 管“Unity 要不要保存和在 Inspector 显示”;Inspector 特性管“编辑器如何展示”。三者职责不同,不能混为一谈。九、Unity 常用组件
Unity 采用组件化(Component-based)结构:
GameObject 是对象容器,真正的能力由挂在它身上的组件提供。自己的
MonoBehaviour 脚本在挂到 GameObject 后,也是一种组件。一个 GameObject 可以同时挂多个组件;组件协作才形成一个角色、摄像机或 UI 对象。1. Transform:每个 GameObject 都有的基础组件
Transform 是每个 GameObject 必有的组件,负责:- Position:位置。
- Rotation:旋转。
- Scale:缩放。
- Parent / Child:层级关系。
对不参与物理的对象,直接修改 Transform 很自然。对动态
Rigidbody2D 或 Rigidbody,应通过刚体 API 移动,而不是一边由物理系统计算、一边手改 Transform。2. Rigidbody 与 Rigidbody2D:让对象参与物理
组件 | 负责内容 | 常见用途 |
Rigidbody | 3D 物理:速度、重力、力、碰撞响应 | 3D 角色、箱子、车辆、抛射物。 |
Rigidbody2D | 2D 物理:二维速度、重力、力、碰撞响应 | 横版角色、2D 子弹、2D 推箱子。 |
关键点:
- Rigidbody/Rigidbody2D 不是“碰撞形状”,而是“会被物理系统模拟的刚体”。
- 使用
Rigidbody2D就配Collider2D;使用Rigidbody就配 3DCollider。
- 2D 与 3D 物理系统是分开的,
OnCollisionEnter2D不会因为 3D Collider 而触发,反过来也一样。
3. Collider 与 Collider2D:定义碰撞范围
Collider 定义物体的几何边界,本身不负责渲染。
常见 2D Collider:
BoxCollider2D:矩形,如箱子、平台。
CircleCollider2D:圆形,如球、圆形范围。
CapsuleCollider2D:胶囊形,常用于人物。
PolygonCollider2D:不规则 2D 轮廓。
常见 3D Collider:
BoxCollider、SphereCollider、CapsuleCollider、MeshCollider。
若勾选
Is Trigger,它通常用于“进入范围”“拾取区域”等触发事件,而不是物理阻挡。要有实际碰撞响应,则要正确组合 Collider、Rigidbody(或 Rigidbody2D)和物理层设置。4. SpriteRenderer、MeshRenderer 与 SkinnedMeshRenderer:把对象显示出来
组件 | 用途 | 典型对象 |
SpriteRenderer | 绘制 2D Sprite | 2D 角色、图标、地面图片。 |
MeshRenderer | 绘制普通 3D Mesh | 墙、道具、静态模型。 |
SkinnedMeshRenderer | 绘制绑定骨骼、可变形的 3D Mesh | 3D 人物和带骨骼动画的模型。 |
Renderer 决定“看得到什么”,Collider 决定“碰撞到什么”,两者是不同职责。看起来有图像的物体不一定能碰撞,有 Collider 的透明物体也可能阻挡角色。
5. Animator:管理动画状态与过渡
Animator 通过 Animator Controller 管理动画状态机,例如:脚本通常只负责设置参数:
Animator Controller 决定何时从 Idle 过渡到 Run、从 Jump 过渡到 Fall。常见错误是参数名拼错,或只给对象加了 Animator 却没有分配正确的 Controller。
6. AudioSource:播放音效与音乐
AudioSource 负责从对象位置或全局播放音频;AudioClip 是具体的声音资源。常见用途:
PlayOneShot:跳跃、攻击、受击等短音效。
Play:播放 AudioSource 当前设置的 Clip。
- 3D 音效:位置、距离衰减、空间混音由 AudioSource 配置。
7. Camera:决定最终看到什么
Camera 将场景从指定视角渲染到屏幕或 Render Texture。它决定:
- 玩家看到的区域。
- 透视或正交投影方式。
- 画面深度、背景、裁剪范围。
- 多个 Camera 的渲染顺序与目标。
2D 项目常使用 Orthographic Camera;3D 项目通常使用 Perspective Camera。摄像机跟随角色是“脚本修改 Camera 的 Transform”,而 Camera 本身负责将画面输出。
8. 其他高频组件
Light:3D 场景光照。
Canvas:UGUI 的根容器。
Button、Image、TextMeshProUGUI:常见 UI 组件。
ParticleSystem:粒子特效。
Tilemap/TilemapCollider2D:2D 地图和对应碰撞。
先记住组件职责,再记 API。 当你要“显示、碰撞、播放声音、播放动画、跟随镜头”时,先判断需要哪个组件,再决定如何从脚本引用它。
十、Unity 组件获取方式
第九点解决“有哪些能力”,第十点解决“脚本怎样拿到某个具体能力”。
例如:
这行没有创建新的 Rigidbody2D,只是声明了一个变量,准备保存对某个已存在组件的引用。只有把引用赋给它之后,才能操作真实组件。
1. Inspector 拖拽引用:显式配置外部依赖
在 Inspector 中把对象或组件拖进对应槽位。Unity 会将这个引用随场景/Prefab 序列化保存。
适合:
- 目标是其他 GameObject,例如敌人追踪的玩家。
- 目标由不同 Prefab 或场景配置决定。
- 希望引用关系在 Inspector 可见、可由策划/关卡配置。
优点:没有运行时查找,关系直观。
风险:忘拖、拖错或运行时销毁目标时,字段可能为
null。对必需引用,可在 Awake 或 OnValidate 中尽早报错。2. GetComponent<T>():获取当前 GameObject 上的组件
GetComponent\<T\>() 的含义是:常见例子:
它不会自动到父物体、子物体或全场景搜索。组件不在同一个 GameObject 时,要使用后面的层级查找,或通过 Inspector 传入明确引用。
对“脚本运行必须存在”的自身组件,可配合:
RequireComponent 会在添加脚本时要求/补充对应组件,减少遗漏配置;它不是替代空引用排查的万能手段。3. TryGetComponent<T>():获取可选组件并立即判断
它返回
bool,把“是否找到”和“拿到的引用”放在一处表达,适合组件可有可无的情况。Unity 文档还指出:在 Editor 中请求的组件不存在时,它避免了 GetComponent 的相关分配。若组件是必需依赖,使用
GetComponent + RequireComponent 往往更能表达“缺失就是配置错误”;若组件是可选能力,TryGetComponent 的语义更清晰。4. GetComponentInChildren<T>():查当前对象和子层级
若
PlayerController 在 Player 上,而 Animator 在 Model 子节点:它会先检查当前 GameObject,再向下递归查找子对象。默认不查找未激活的子对象;需要时可写:
若层级中可能有多个同类型组件,单个
GetComponentInChildren 只会返回第一个匹配项,且不要把“第一个”当成稳定业务规则;需要全部结果时使用 GetComponentsInChildren\<T\>() 后自行筛选。5. GetComponentInParent<T>():查当前对象和父层级
Hitbox 想通知拥有它的角色:
它同样会先检查当前 GameObject,然后向父级逐层查找。默认只包含激活层级;必要时可传入
true 以包含未激活对象。常见用途:子碰撞器、武器节点、特效节点需要找到父角色的控制器。不要把它误认为“只查父对象”——当前对象也在搜索范围内。
6. 为什么通常要在 Awake 缓存组件
不推荐每帧重复搜索:
更清晰的写法:
缓存的价值:
- 避免反复查找,减少不必要工作。
- 让依赖集中在初始化阶段,代码更容易检查。
- 后续逻辑只关心“怎么使用组件”,不反复混入“怎么找到组件”。
Awake 通常适合初始化自身固定组件;Start 常适合依赖其他对象已经完成 Awake 的初始化。无论哪种方式,都应让“引用何时准备好”在代码中清楚可见。不要把“缓存”理解成死规则:如果组件会被运行时替换、销毁或对象池复用,就要在相应生命周期重新获取或验证引用。
7. 组件获取方式怎么选
方式 | 查找范围 | 适合场景 |
Inspector [SerializeField] | 你显式拖入的任意对象/组件 | 外部目标、UI、Prefab 配置、场景关系。 |
GetComponent<T>() | 当前 GameObject | 当前对象必备的 Rigidbody、Collider、同物体 Animator 等。 |
TryGetComponent<T>() | 当前 GameObject | 组件可选,需要先判断是否存在。 |
GetComponentInChildren<T>() | 当前对象 + 子层级 | 模型节点 Animator、武器子节点、子 UI。 |
GetComponentInParent<T>() | 当前对象 + 父层级 | 子碰撞器、武器节点访问父角色控制器。 |
最终选择依据是:组件在哪里、它是不是必需、引用关系是否应该让 Inspector 显式配置,而不是死记某一个 API。
十一、代码状态机的生命周期与转换边界
这里讨论由项目代码实现的有限状态机,不是 Animator Controller。它把一个对象在同一时刻允许执行的行为拆成独立状态,例如 Idle、Walk、Jump、Air 和 Dash。
1. 代码状态机解决什么问题
不使用状态机时,移动、跳跃、冲刺和攻击条件可能集中在一个 Update 中并互相覆盖。状态机保存一个
currentState,每帧只更新当前状态,让“此刻允许执行什么”有明确入口。状态机不会自动理解 Idle 应该停止、Jump 应该上升;这些仍然要由各状态的 Enter、Update 和 Exit 明确实现。
2. currentState、Initialize 与 ChangeState
Initialize 建立第一个有效状态;ChangeState 先退出旧状态,再替换引用,最后进入新状态。3. Enter、Update 与 Exit
阶段 | 调用时机 | 适合行为 |
Enter | 进入状态时一次 | 设置跳跃初速度、初始化计时器、开启动画参数 |
Update | 状态持续期间每帧 | 读取输入、递减计时器、更新持续行为、检测转换条件 |
Exit | 离开状态时一次 | 关闭动画参数、清理只属于旧状态的数据 |
一次性行为放进 Update 会被重复执行。例如每帧把
velocity.y 设为正的跳跃速度,会持续覆盖重力结果,使角色无法正常到达最高点并下降。4. 状态计时器与 <= 0
计时器通常在 Enter 初始化,在公共 Update 中每帧递减:
结束条件通常写成:
离散更新不保证数值刚好经过 0,可能从一个很小的正数直接变成负数。
<= 0 同时覆盖归零和越过零;不要依赖浮点数精确等于 0。同一个计时器只应在一个位置递减。若基类 Update 已经处理,子类再次减一次会让状态持续时间缩短。
5. ChangeState 不会终止当前方法
ChangeState 会更换状态机保存的引用,但调用它的旧 Update 仍会继续执行后面的 C# 语句。若后面还有其他状态条件,同一帧可能连续切换多次。
return 用来结束旧状态当前这一次方法调用。多个转换条件还应明确优先级,不能只依赖代码偶然的排列顺序。6. 状态对行为和数据的所有权
状态机拆分以后,每种持续数据都要有明确维护者。例如 Walk 写入水平速度、Jump 在 Enter 写入一次跳跃初速度、Air 检测落地、Idle 清除主动水平速度。
如果旧状态写入了速度,新状态只改变动画而没有接管速度,Rigidbody2D 会继续保留旧物理状态。状态切换成功不等于速度、动画和输入意图已经自动同步。
速度的持久性和分量控制参见第六点。本节只负责状态生命周期与转换规则,不重复 Rigidbody2D 的物理原理。
7. 本节常见错误
- 把一次性的跳跃初速度放在 Update,导致每帧重复写入。
- 调用 ChangeState 后继续执行旧状态逻辑,同一帧又切换到第二个状态。
- 基类和子类同时递减同一个 stateTimer。
- 用刚体残留速度代替输入意图判断 Idle 或 Walk。
- 状态只切换动画参数,却没有明确谁负责接管旧速度。
