Ch06 --- 物理系统
6.1 Rigidbody 刚体的本质
什么是 Rigidbody? 给 GameObject 加上 Rigidbody 后,Unity 的物理引擎(PhysX)就会接管这个对象的运动------它会受到重力、碰撞、摩擦力、施加的力等物理作用。没有 Rigidbody 的 Collider 是"静态碰撞体"(Static Collider),只挡其他物体,自己不动。
为什么物理操作要在 FixedUpdate? Update 的调用频率随帧率波动,如果在 Update 里 AddForce,60FPS时每秒施力60次,30FPS时只有30次,导致物理行为不一致。FixedUpdate 以固定时间步长调用(默认0.02s/50Hz),物理结果可重现、与帧率无关。
isKinematic 是什么? Kinematic(运动学)模式的 Rigidbody 不受物理力的影响,但仍然参与碰撞检测。用于"用代码/动画精确控制位置,同时影响周围物体"的场景,比如平台、电梯、Boss的部件。
csharp
Rigidbody rb = GetComponent<Rigidbody>();
// 施力(在 FixedUpdate 中)
rb.AddForce(Vector3.forward * 10f); // 持续力(F=ma)
rb.AddForce(Vector3.up * 500f, ForceMode.Impulse); // 冲量(瞬间,用于跳跃/爆炸)
rb.AddForce(Vector3.up * 5f, ForceMode.Acceleration); // 加速度(不考虑质量)
rb.AddForce(Vector3.up * 5f, ForceMode.VelocityChange); // 直接改速度(不考虑质量,最直接)
// 约束自由度(防止角色倒下、只在水平面移动)
rb.constraints = RigidbodyConstraints.FreezeRotation; // 冻结所有旋转(角色控制器必用)
rb.constraints = RigidbodyConstraints.FreezePositionY // 冻结Y轴位置
| RigidbodyConstraints.FreezeRotationX
| RigidbodyConstraints.FreezeRotationZ;
// 高速物体防穿透(子弹、高速车辆)
rb.collisionDetectionMode = CollisionDetectionMode.Continuous;
// 默认 Discrete:每帧检查一次位置,高速物体可能"穿墙"
// Continuous:连续检测,性能开销更大但不会穿透
6.2 碰撞器的选择
原则: 碰撞器形状越简单,物理计算越快。优先选择 Box、Sphere、Capsule;复杂模型用多个简单碰撞体组合,而不是 MeshCollider(尤其避免非凸形 MeshCollider)。
Trigger(触发器)vs Collider(碰撞体)区别:
isTrigger = false:实体碰撞,物体会被阻挡,触发OnCollision*事件isTrigger = true:穿透,只检测进出,触发OnTrigger*事件
触发器适合:拾取道具、进入区域检测、技能命中范围------这些场景只需要知道"是否接触",不需要物理阻挡。
csharp
// 碰撞事件(双方都有Collider,至少一方有Rigidbody)
void OnCollisionEnter(Collision col) {
// col.contacts[0].point → 碰撞点世界坐标
// col.contacts[0].normal → 碰撞面法线(用于弹射方向计算)
// col.impulse.magnitude → 碰撞冲量大小(判断碰撞强度)
float impactForce = col.impulse.magnitude;
if (impactForce > damageThreshold)
TakeDamage(impactForce * damageMultiplier);
}
// 触发器事件(进入危险区域、拾取道具)
void OnTriggerEnter(Collider other) {
if (other.CompareTag("Pickup")) {
other.GetComponent<Item>().Collect(this);
Destroy(other.gameObject);
}
}
6.3 射线检测(Raycast)
射线检测的意义: 射线检测不是用于碰撞,而是用于"查询"------查询某个方向上是否有物体、是什么物体、在什么位置。应用场景:鼠标点击选择3D对象、检测前方是否有墙、武器射击命中判定、AI视线检测。
性能优化关键: 一定要使用 LayerMask 过滤不需要检测的层。没有 LayerMask 时,射线会检测场景中所有碰撞体,大量场景时性能下降明显。
csharp
// 基础射线(最常用)
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); // 鼠标射线
if (Physics.Raycast(ray, out RaycastHit hit, maxDistance: 100f, layerMask: groundLayer)) {
Debug.DrawLine(ray.origin, hit.point, Color.red, 1f); // 在Scene中可视化(调试用)
// hit.point → 碰撞点世界坐标
// hit.normal → 碰撞面法线(在斜面放置对象时用)
// hit.distance → 射线起点到碰撞点的距离
// hit.collider → 碰撞到的碰撞体
MoveCharacterTo(hit.point);
}
// 多结果射线(检测穿透多个物体)
// 使用 NonAlloc 版本:复用数组,零GC分配
RaycastHit[] results = new RaycastHit[10]; // 在类中声明,复用
void CheckPenetration() {
int count = Physics.RaycastNonAlloc(transform.position, transform.forward, results, 10f);
for (int i = 0; i < count; i++)
Debug.Log($"命中: {results[i].collider.name}");
}
// 范围检测(不是射线,而是球形/盒形区域)
Collider[] inRange = Physics.OverlapSphere(transform.position, 5f, enemyLayer);
// ⚡ NonAlloc版本(零GC)
Collider[] buffer = new Collider[20];
int count = Physics.OverlapSphereNonAlloc(transform.position, 5f, buffer, enemyLayer);
6.4 PhysicsMaterial------摩擦力与弹性
PhysicsMaterial 的作用: 决定碰撞体表面的物理属性,控制两个维度:
- Friction(摩擦力): 静止摩擦(阻止开始滑动)和动态摩擦(运动中的阻力)
- Bounciness(弹性): 0 = 完全不弹,1 = 完全弹性碰撞
Combine Mode(组合模式): 两碰撞体属性如何合并 ------ Average/Minimum/Maximum/Multiply。关键规则:Minimum 优先于 Average,Maximum 优先于 Minimum。
csharp
// 运行时动态切换物理材质(进入冰面区域)
void EnterIceZone(Collider col) {
var ice = new PhysicsMaterial("Ice") {
dynamicFriction = 0.02f,
staticFriction = 0.02f,
bounciness = 0f,
frictionCombine = PhysicsMaterialCombine.Minimum, // 两者取最小(确保冰面效果)
bounceCombine = PhysicsMaterialCombine.Average
};
col.material = ice;
}
// 弹力球(Bounciness=1 理论上无限弹跳)
void SetBouncy(Collider col) {
col.material = new PhysicsMaterial("Bouncy") {
bounciness = 0.9f,
bounceCombine = PhysicsMaterialCombine.Maximum // 与任何表面碰都弹
};
}
6.5 关节系统(Joint)------物理连接
关节系统的本质: 在两个 Rigidbody 之间施加约束力,限制相对运动的自由度。
| 关节类型 | 自由度 | 典型用途 |
|---|---|---|
FixedJoint |
完全锁死 | 焊接(子弹嵌墙、可破坏结构) |
HingeJoint |
1个旋转轴 | 门、车轮、盖子 |
SpringJoint |
弹簧伸缩 | 悬挂系统、橡皮筋 |
ConfigurableJoint |
全自定义 | 角色布娃娃、复杂机械 |
breakForce(断裂力): 超过此力时关节自动断裂,OnJointBreak 回调触发,适合制作可破坏物体。
csharp
// 动态创建铰链关节(门)
void CreateHingeDoor(GameObject door, Rigidbody frame) {
var hinge = door.AddComponent<HingeJoint>();
hinge.connectedBody = frame;
hinge.axis = Vector3.up; // 绕Y轴旋转
hinge.useLimits = true;
hinge.limits = new JointLimits { min = 0f, max = 90f, bounciness = 0.05f };
hinge.useSpring = true;
hinge.spring = new JointSpring { spring = 30f, damper = 5f, targetPosition = 0f }; // 自动关门
}
// 可破坏关节
void CreateBreakableJoint(Rigidbody a, Rigidbody b) {
var joint = a.gameObject.AddComponent<FixedJoint>();
joint.connectedBody = b;
joint.breakForce = 500f; // 超过500N断裂
joint.breakTorque = 200f;
}
void OnJointBreak(float breakForce) {
Debug.Log($"关节断裂!力度: {breakForce}N");
// 播放断裂音效、生成碎片粒子
}
6.6 物理性能优化
Layer Collision Matrix 是最高效的优化手段: Project Settings → Physics 中关闭不需要互相检测的层对,物理引擎直接跳过这些检测,性能提升立竿见影。例如:UI层与敌人层无需碰撞检测,关闭后节省大量 broadphase 计算。
Rigidbody 休眠机制: 速度低于 Physics.sleepThreshold(默认 0.005 m/s)时自动休眠,停止所有物理计算。场景中大量静止物体完全不消耗CPU。
csharp
// 手动控制休眠(批量优化不活跃的对象)
void SleepAllInactiveEnemies(List<Rigidbody> enemies) {
foreach (var rb in enemies)
if (rb.linearVelocity.sqrMagnitude < 0.01f) rb.Sleep();
}
// 提高休眠阈值(移动端适用,减少计算量)
void Awake() {
Physics.sleepThreshold = 0.02f; // 更快进入休眠
Physics.defaultContactOffset = 0.01f; // 减小接触偏移(精度vs性能权衡)
}
// ⚡ 关闭自动物理模拟(适合回合制、战棋、录像回放)
void ManualPhysicsUpdate(float deltaTime) {
Physics.autoSimulation = false;
Physics.Simulate(deltaTime); // 完全手动控制物理步进
}
Ch07 --- 摄像机系统
7.1 摄像机基础概念
透视摄像机 vs 正交摄像机:
- 透视(Perspective): 近大远小,符合人眼视觉,用于3D游戏
- 正交(Orthographic): 物体大小与距离无关,用于2D游戏、策略游戏、UI相机
FOV(视野角): FOV越大,视野越广(类似鱼眼效果);越小,视野越窄(类似望远镜)。快节奏游戏(FPS冲刺)常动态增大FOV制造速度感;瞄准时缩小FOV模拟放大镜效果。
近/远裁剪面: 摄像机只渲染这两个面之间的物体。太近的物体穿透近裁剪面会闪烁;太远的物体会被裁剪消失。调小远裁剪面可以显著提升性能(减少渲染的物体数量)。
csharp
Camera cam = Camera.main; // 获取标签为 MainCamera 的摄像机
// 常用属性调整
cam.fieldOfView = 75f; // 视野角(透视摄像机)
cam.orthographicSize = 8f; // 正交尺寸(屏幕高度的一半,单位:Unity单位)
cam.nearClipPlane = 0.1f; // 近裁剪面(太小会导致Z-fighting)
cam.farClipPlane = 500f; // 远裁剪面
// 坐标转换(常用)
// 屏幕坐标 → 世界射线(点击检测)
Ray ray = cam.ScreenPointToRay(Input.mousePosition);
// 世界坐标 → 屏幕坐标(判断对象是否在视野内、UI跟随)
Vector3 screenPos = cam.WorldToScreenPoint(target.position);
bool isVisible = screenPos.z > 0 // z>0 表示在摄像机前方
&& screenPos.x > 0 && screenPos.x < Screen.width
&& screenPos.y > 0 && screenPos.y < Screen.height;
7.2 为什么推荐 Cinemachine?
手写摄像机的问题: 手写跟随摄像机往往有跟随延迟、边界处理、震屏等需求,代码量大且难以调优。Cinemachine 是 Unity 官方的摄像机框架,通过组件配置实现专业级摄像机效果,内置了阻尼、跟随偏移、边界限制、碰撞避免等功能。
核心概念:
CinemachineBrain:挂在真实 Camera 上,负责融合所有虚拟相机的输出CinemachineVirtualCamera:虚拟摄像机,配置跟随目标和朝向目标Priority:优先级最高的虚拟摄像机被激活- 切换虚拟摄像机时,CinemachineBrain 自动在它们之间平滑过渡
csharp
// 运行时切换摄像机(如进入车辆、开枪瞄准)
[SerializeField] CinemachineVirtualCamera followCam; // 普通跟随
[SerializeField] CinemachineVirtualCamera aimCam; // 瞄准摄像机
void EnterAimMode() {
aimCam.Priority = 20; // 提高优先级,CinemachineBrain自动切换并平滑过渡
followCam.Priority = 10;
}
void ExitAimMode() {
aimCam.Priority = 10; // 降回去,恢复普通跟随
followCam.Priority = 20;
}
// 震屏(CinemachineImpulseSource + CinemachineImpulseListener)
[SerializeField] CinemachineImpulseSource impulse;
void OnExplosion() {
impulse.GenerateImpulse(0.5f); // 震屏强度0.5
}
7.3 多摄像机渲染------Camera Depth 与 Clear Flags
为什么需要多摄像机? 单摄像机时,近裁剪面调大会裁掉近处物体,调小会产生 Z-fighting。典型方案:主摄像机渲染场景,武器摄像机单独渲染武器(独立的近裁面),彻底解决武器穿墙问题。
Camera.depth: 值越大越后渲染(后渲染的内容覆盖先渲染的)。
Clear Flags 决定每帧如何清除画面:
Skybox:清除并绘制天空盒(主摄像机用)Depth Only:只清除深度缓冲,保留颜色(叠加渲染,武器摄像机用)Don't Clear:完全不清除(运动模糊特效用)
csharp
// 武器不穿墙:双摄像机方案配置
void SetupWeaponCamera(Camera mainCam, Camera weaponCam) {
// 主摄像机(depth=0):渲染场景,排除武器层
mainCam.depth = 0;
mainCam.clearFlags = CameraClearFlags.Skybox;
mainCam.cullingMask = ~(1 << LayerMask.NameToLayer("Weapon"));
// 武器摄像机(depth=1):只渲染武器,只清深度
weaponCam.depth = 1; // 后渲染,覆盖主摄
weaponCam.clearFlags = CameraClearFlags.Depth; // ⚡ 关键:只清深度!
weaponCam.cullingMask = 1 << LayerMask.NameToLayer("Weapon");
weaponCam.nearClipPlane = 0.01f; // 超近裁剪面,武器不被裁
weaponCam.farClipPlane = 10f;
weaponCam.fieldOfView = mainCam.fieldOfView; // 保持相同FOV
}
7.4 RenderTexture------渲染到纹理
核心用途: 将摄像机的渲染结果输出到一张纹理,而不是屏幕。用于小地图、监控画面、传送门效果、游戏内截图。
内存注意: RenderTexture 占用显存(原生内存),不受 GC 管理,必须手动 Release + Destroy。
csharp
// 小地图系统
public class MinimapSystem : MonoBehaviour {
[SerializeField] Camera minimapCam; // 俯视摄像机
[SerializeField] RawImage minimapDisplay; // UI上的RawImage
RenderTexture rt;
void Start() {
rt = new RenderTexture(256, 256, 0, RenderTextureFormat.RGB565);
rt.Create();
minimapCam.targetTexture = rt; // 摄像机输出到RT
minimapDisplay.texture = rt; // UI显示RT
}
void OnDestroy() {
if (rt != null) { rt.Release(); Destroy(rt); } // ⚠️ 必须手动释放!
}
}
// 游戏内截图(WaitForEndOfFrame保证渲染完成)
IEnumerator CaptureScreenshot() {
yield return new WaitForEndOfFrame();
var tex = new Texture2D(Screen.width, Screen.height, TextureFormat.RGB24, false);
tex.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0);
tex.Apply();
string path = Path.Combine(Application.persistentDataPath,
$"Screenshot_{System.DateTime.Now:yyyyMMdd_HHmmss}.png");
System.IO.File.WriteAllBytes(path, tex.EncodeToPNG());
Destroy(tex); // 清理
Debug.Log($"截图保存: {path}");
}
7.5 后处理效果(Post Processing Volume)
Volume 工作原理: Post Processing Volume 组件定义一个区域的后处理配置(Bloom/Vignette/Color Grading),摄像机进入该区域时平滑过渡到对应配置。Global Volume 对整个场景生效。
运行时动态修改后处理: 低血量红色晕边、水下蓝色滤镜、死亡黑白效果------通过代码修改 Volume 参数实现。
csharp
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class PostProcessController : MonoBehaviour {
[SerializeField] Volume globalVolume;
Vignette vignette;
ColorAdjustments colorAdj;
void Start() {
globalVolume.profile.TryGet(out vignette);
globalVolume.profile.TryGet(out colorAdj);
}
// 低血量效果(晕边变红)
public void SetLowHealthEffect(float healthPercent) {
if (vignette == null) return;
float intensity = Mathf.Lerp(0.5f, 0.1f, healthPercent); // 血量越少晕边越强
vignette.intensity.Override(intensity);
vignette.color.Override(Color.Lerp(Color.red, Color.black, healthPercent));
}
// 死亡黑白效果
public void SetDeathEffect() {
if (colorAdj != null)
colorAdj.saturation.Override(-100f); // -100=完全去色(黑白)
}
}
Ch08 --- UI系统(UGUI)
8.1 Canvas 渲染模式的选择
三种 Canvas 渲染模式的适用场景:
| 模式 | 特点 | 适用 |
|---|---|---|
| Screen Space - Overlay | UI永远在最上层,不受摄像机裁剪影响 | HUD、血量条、小地图、背包界面 |
| Screen Space - Camera | UI与指定摄像机绑定,可实现3D UI效果 | 需要粒子穿越UI、3D物体叠加UI |
| World Space | UI是3D世界中的一个面板 | 头顶血量、3D游戏内菜单、VR UI |
Canvas Scaler(分辨率适配) 是 UI 适配的关键:
Scale With Screen Size:根据参考分辨率(如1920×1080)自动缩放Match:0=按宽缩放,1=按高缩放,0.5=折中(推荐横屏游戏用0.5)- 不设置 Canvas Scaler:UI在不同分辨率设备上大小不一致
8.2 RectTransform 锚点系统
锚点(Anchor)的本质: 锚点是 UI 元素相对父容器的"附着点",用0-1的比例表示。当父容器尺寸改变时,UI 元素相对锚点的距离保持不变。
常见配置:
- 锚点四角相同(如
min=max=(0.5, 0.5)):固定尺寸,围绕中心定位,适合固定大小的按钮 - 锚点四角分开(如
min=(0,0), max=(1,1)):随父容器拉伸,适合背景、容器面板
csharp
RectTransform rt = GetComponent<RectTransform>();
// 锚点固定在中心(固定尺寸UI)
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
rt.anchoredPosition = new Vector2(0, -100); // 距锚点中心向下100像素
rt.sizeDelta = new Vector2(300, 60); // 尺寸 300×60
// 锚点铺满父容器(会随父容器大小变化)
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(10, 10); // 左下边距
rt.offsetMax = new Vector2(-10, -10); // 右上边距(负数=向内)
8.3 常用 UI 组件详解
Image.fillAmount 是实现技能冷却、血量弧形进度条的关键属性:
csharp
// 技能冷却进度(圆形进度)
Image skillIcon;
float cooldownTime = 3f, timer;
void Update() {
timer += Time.deltaTime;
// type=Filled, fillMethod=Radial360, fillAmount=0→冷却中,1→可用
skillIcon.fillAmount = Mathf.Clamp01(timer / cooldownTime);
}
// TMP_Text(TextMeshPro,比老Text性能好10倍以上)
TMP_Text label;
// 避免每帧 string 拼接(产生GC)
// ❌ label.text = "HP: " + currentHP + "/" + maxHP;
// ✅ 用 StringBuilder 或 TMP 的 SetText
label.SetText("HP: {0}/{1}", currentHP, maxHP); // TMP内置格式化,零GC
// Button 事件(两种绑定方式)
Button btn;
btn.onClick.AddListener(OnButtonClick); // 代码绑定
btn.onClick.AddListener(() => DoSomething(42)); // Lambda(注意闭包陷阱)
// ScrollRect 滚动控制
ScrollRect scrollView;
scrollView.verticalNormalizedPosition = 0f; // 滚动到底部
scrollView.verticalNormalizedPosition = 1f; // 滚动到顶部
8.4 UGUI 性能优化------合批规则
什么会破坏合批? UGUI 将相邻且满足条件的 UI 元素合并为一个 Draw Call。破坏合批的主要原因:
- 不同材质或 Atlas(最常见)
- 中间够进了另一种材质的元素(层次覆盖)
- 使用了
Mask/RectMask2D组件
Dynamic vs Static Canvas 分离: 频繁更新的元素(血量条、冷却进度)和静态布局(技能图标、设置面板)放入不同 Canvas。静态Canvas不会因动态元素更新而重建渲染树。
csharp
// Canvas 分离策略
public class UICanvasManager : MonoBehaviour {
[Header("静态Canvas(不更新的布局)")]
[SerializeField] Canvas staticCanvas; // 技能图标、小地图框、设置按鈕
[Header("动态Canvas(高频更新)")]
[SerializeField] Canvas dynamicCanvas; // 血量数字、冷却计时、Buff图标
[Header("弹窗Canvas(按需开关)")]
[SerializeField] Canvas popupCanvas; // 背包、地图、对话框
// 强制重建 UI(次需要时)
public void ForceRebuildStaticUI() =>
UnityEngine.UI.LayoutRebuilder.ForceRebuildLayoutImmediate(
staticCanvas.GetComponent<RectTransform>());
}
// ⚠️ 常见错误:在 Update 里调用 Canvas.ForceUpdateCanvases()
// 正确方式:只在布局改变时调用一次
void OnInventoryChanged() {
Canvas.ForceUpdateCanvases(); // 立即刷新布局
}
8.5 UI 对象池------虚拟列表(Virtual Scroll List)
问题: 1000个列表项 = 1000个 GameObject + 1000个 Image + 1000个 Text,内存爆炸。
方案: 只创建屏幕可见数量 + 缓冲(通常 可见数+3)的 GameObject,滚动时复用(移动不可见的元素到新位置并更新数据)。
csharp
// 简化虚拟列表实现原理
public class VirtualScrollList : MonoBehaviour {
[SerializeField] RectTransform content; // ScrollRect的Content
[SerializeField] GameObject itemPrefab; // 列表项Prefab
[SerializeField] float itemHeight = 80f;
List<string> _data = new();
List<GameObject> _items = new(); // 可见的少量元素
int _firstVisible = 0;
int _visibleCount;
ScrollRect _scroll;
void Awake() {
_scroll = GetComponentInParent<ScrollRect>();
_scroll.onValueChanged.AddListener(_ => RefreshVisibility());
// 只建屏幕可见数+3个GameObject
_visibleCount = Mathf.CeilToInt(GetComponent<RectTransform>().rect.height / itemHeight) + 3;
for (int i = 0; i < _visibleCount; i++)
_items.Add(Instantiate(itemPrefab, content));
}
public void SetData(List<string> data) {
_data = data;
content.sizeDelta = new Vector2(0, data.Count * itemHeight); // 设置Content高度
RefreshVisibility();
}
void RefreshVisibility() {
float scrollY = content.anchoredPosition.y;
_firstVisible = Mathf.FloorToInt(scrollY / itemHeight);
_firstVisible = Mathf.Clamp(_firstVisible, 0, Mathf.Max(0, _data.Count - _visibleCount));
for (int i = 0; i < _items.Count; i++) {
int dataIndex = _firstVisible + i;
bool visible = dataIndex < _data.Count;
_items[i].SetActive(visible);
if (visible) {
// 移动到正确位置并更新数据
var rt = _items[i].GetComponent<RectTransform>();
rt.anchoredPosition = new Vector2(0, -dataIndex * itemHeight);
_items[i].GetComponentInChildren<TMP_Text>().text = _data[dataIndex];
}
}
}
}
8.6 EventSystem 与自定义拖拽
UI 事件接口: UGUI 通过接口实现自定义事件响应,不需要子类继承。常用接口:
IPointerClickHandler、IPointerEnterHandler、IPointerExitHandlerIDragHandler、IBeginDragHandler、IEndDragHandlerIDropHandler:目标结束拖拽后接受拖拽物
防止 UI 点击穿透: 当点击 UI 按鈕时,不应该同时触发3D世界的射线检测。
csharp
// 背包拖拽(实现 IDragHandler + IDropHandler)
public class DraggableItem : MonoBehaviour,
IBeginDragHandler, IDragHandler, IEndDragHandler {
CanvasGroup _canvasGroup;
RectTransform _rt;
Vector2 _startPos;
Transform _startParent;
void Awake() {
_canvasGroup = GetComponent<CanvasGroup>();
_rt = GetComponent<RectTransform>();
}
public void OnBeginDrag(PointerEventData e) {
_startPos = _rt.anchoredPosition;
_startParent = transform.parent;
transform.SetParent(transform.root); // 移到最顶层(防止被截切)
_canvasGroup.blocksRaycasts = false; // 允许射线穿透自身检测到目标
}
public void OnDrag(PointerEventData e) {
RectTransformUtility.ScreenPointToLocalPointInRectangle(
transform.root as RectTransform, e.position, e.pressEventCamera, out Vector2 localPos);
_rt.anchoredPosition = localPos;
}
public void OnEndDrag(PointerEventData e) {
_canvasGroup.blocksRaycasts = true;
// 如果没有成功投入检隐格,回到套始位置
transform.SetParent(_startParent);
_rt.anchoredPosition = _startPos;
}
}
// 在Update中防止 UI 点击穿透刻3D世界
void Update() {
if (Input.GetMouseButtonDown(0)) {
// 点击在UI上时不处理 3D 射线
if (EventSystem.current.IsPointerOverGameObject()) return;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit)) Select(hit.collider.gameObject);
}
}
Ch09 --- 动画系统
9.1 Animator 状态机的设计原则
什么是动画状态机? 角色在任意时刻处于某一个"动画状态"(Idle/Run/Jump/Attack),状态之间根据条件切换。Animator 状态机可视化地管理这些状态和切换条件,避免手写复杂的 if-else 判断。
参数类型选择:
Bool:持续状态(isRunning、isGrounded)Float:数值驱动(speed、direction)Trigger:一次性触发(jump、attack、die),用完自动重置Integer:枚举状态(weaponType=0/1/2)
过渡(Transition)设置要点: Has Exit Time 决定是否等当前动画播完才切换。战斗游戏中攻击→Idle 通常取消勾选(立即响应下一次攻击);角色死亡动画通常需要勾选(播完才切换)。
csharp
Animator anim = GetComponent<Animator>();
// 设置参数(驱动状态切换)
anim.SetFloat("Speed", rb.velocity.magnitude);
anim.SetBool("IsGrounded", isGrounded);
anim.SetTrigger("Jump"); // 自动在一帧后重置
anim.ResetTrigger("Jump"); // 手动重置(避免缓存触发)
// 查询当前状态(用于控制其他系统)
AnimatorStateInfo info = anim.GetCurrentAnimatorStateInfo(layerIndex: 0);
bool isAttacking = info.IsName("Attack"); // 按名字判断
float progress = info.normalizedTime % 1f; // 0-1,当前动画进度
bool isLooping = info.loop;
// 播放指定动画(跳过状态机直接播放,调试时用)
anim.Play("Run", 0, normalizedTime: 0f); // 从0%开始播放
anim.CrossFade("Walk", 0.2f); // 带0.2秒过渡时间
9.2 BlendTree------流畅过渡动画
BlendTree 的作用: 将多个动画按权重混合,实现平滑过渡。比如根据 speed 值在 Idle/Walk/Run 之间无缝混合,避免硬切换的突变感。
1D BlendTree: 用一个 Float 参数混合多个动画(适合速度驱动的移动)
2D BlendTree: 用两个 Float 参数混合(适合8方向移动、瞄准偏移)
csharp
// 移动动画(1D BlendTree)
void Update() {
float speed = new Vector3(rb.velocity.x, 0, rb.velocity.z).magnitude;
// BlendTree配置:0.0=Idle, 0.5=Walk, 1.0=Run
anim.SetFloat("Speed", speed / maxSpeed, dampTime: 0.1f, Time.deltaTime);
// dampTime: 参数变化的平滑时间,避免动画突变
}
// 瞄准偏移(2D BlendTree)
void LateUpdate() {
// 计算瞄准目标相对角色的偏移角度
Vector3 dir = (aimTarget.position - aimOrigin.position).normalized;
float yaw = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg;
float pitch = -Mathf.Asin(dir.y) * Mathf.Rad2Deg;
anim.SetFloat("AimHorizontal", yaw / 90f); // 归一化到 -1~1
anim.SetFloat("AimVertical", pitch / 60f);
}
9.3 Avatar Mask 与动画分层
Avatar Mask 的作用: 限制动画影响的骨骼范围。例如:角色跟a时上半身播放攻击动画,下半身继续距。没有 Avatar Mask,攻击动画会影响全身。
Animation Layer(动画层): Animator 支持多层动画叠加。底层(Layer 0)渲染移动/待机,上层加上 Avatar Mask 只影响上半身。Layer Weight 可动态调整(武器拔出动作渐入)。
csharp
Animator anim = GetComponent<Animator>();
// 读取/设置层权重(第二层指数 1)
float aimLayerWeight = anim.GetLayerWeight(1);
anim.SetLayerWeight(1, Mathf.Lerp(aimLayerWeight, 1f, 10f * Time.deltaTime)); // 平滑过渡
// 层的 Blend Mode:
// Override:直接覆盖底层(攻击动画)
// Additive:叠加到底层上(第一人称头部上下摇动)
// 执行序列:进入瑛准状态→层权重渐变为 1
void EnterAimState() {
anim.SetBool("IsAiming", true);
StartCoroutine(FadeLayerWeight(1, targetWeight: 1f, duration: 0.2f));
}
void ExitAimState() {
anim.SetBool("IsAiming", false);
StartCoroutine(FadeLayerWeight(1, targetWeight: 0f, duration: 0.2f));
}
IEnumerator FadeLayerWeight(int layer, float targetWeight, float duration) {
float startWeight = anim.GetLayerWeight(layer);
for (float t = 0; t < duration; t += Time.deltaTime) {
anim.SetLayerWeight(layer, Mathf.Lerp(startWeight, targetWeight, t / duration));
yield return null;
}
anim.SetLayerWeight(layer, targetWeight);
}
9.4 Root Motion vs In-Place 动画
Root Motion: 动画文件内嵌入了位移数据,动画播放时角色模型自动移动。优点:动画和移动完全同步;缺点:不易编程控制,不利于动态调整速度。
In-Place: 角色原地跑,位移由代码控制。大多数商业游戏采用此方式(更臵活,可与物理引擎无缝衔合)。
csharp
// 自定义 Root Motion(在 OnAnimatorMove 中接管移动)
// Apply Root Motion = true,但由脚本处理实际移动
void OnAnimatorMove() {
// animator.deltaPosition 是动画这帧应移动的距离
Vector3 deltaPos = anim.deltaPosition;
// 可以在这里加入额外控制逻辑(如冰面滑动效果)
if (IsOnIce) deltaPos *= 1.5f;
// 应用移动
rb.MovePosition(rb.position + deltaPos);
}
// 查询动画内嵌的 Root Motion 信息
void CheckRootMotion() {
Debug.Log($"本帧移动: {anim.deltaPosition}");
Debug.Log($"本帧旋转: {anim.deltaRotation.eulerAngles}");
}
9.5 Inverse Kinematics(IK)------工具沉手动效果
IK 的作用: 正向动力学(FK)是父节点带动子节点。IK 反过来:指定末端位置,自动计算各关节交角度。应用场景:手持武器自动抓枚、脚部贴地面、头部朝向目标。
csharp
// 在 Animator 设置中开启 IK Pass,然后在 OnAnimatorIK 回调中控制
void OnAnimatorIK(int layerIndex) {
if (aimTarget == null) return;
// 头部朝向目标
anim.SetLookAtWeight(
weight: 1f, // 其他部分影响的权重
bodyWeight: 0.3f, // 身体转动
headWeight: 0.7f, // 头部转动
eyesWeight: 1f, // 眼组
clampWeight: 0.5f // 限制最大转动角度
);
anim.SetLookAtPosition(aimTarget.position);
// 右手 IK(拖枪的手把位置)
anim.SetIKPositionWeight(AvatarIKGoal.RightHand, 1f);
anim.SetIKRotationWeight(AvatarIKGoal.RightHand, 1f);
anim.SetIKPosition(AvatarIKGoal.RightHand, rightHandTarget.position);
anim.SetIKRotation(AvatarIKGoal.RightHand, rightHandTarget.rotation);
// 左手 IK(托枪的托手位置)
anim.SetIKPositionWeight(AvatarIKGoal.LeftHand, 0.8f);
anim.SetIKPosition(AvatarIKGoal.LeftHand, leftHandTarget.position);
}
Ch10 --- 音频系统
10.1 音频架构设计
AudioSource vs PlayClipAtPoint:
AudioSource:挂在 GameObject 上,可控制播放/暂停/音量,3D空间音频PlayClipAtPoint:静态方法,在指定位置播放完自动销毁,适合"一次性音效"
PlayOneShot vs Play:
Play():打断当前播放,重新开始PlayOneShot(clip):叠加播放,不打断当前音效。适合快速连续触发的音效(脚步声、枪声),可以有自然的混响叠加效果。
为什么使用 AudioMixer? 没有 Mixer 时,调整主音量需要遍历所有 AudioSource;有了 Mixer,各声音分组独立控制,还可以添加混响/压限等 DSP 效果,以及用 Snapshot 在不同游戏状态间平滑切换音效风格(水下/正常/室内)。
csharp
// 音频管理器(简单版本)
public class AudioManager : Singleton<AudioManager> {
[SerializeField] AudioMixer masterMixer;
[SerializeField] AudioSource bgmSource;
// 使用分贝转换(AudioMixer内部用分贝)
public void SetMasterVolume(float volume01) {
// 将 0-1 的线性值转为分贝(人耳感知是对数的)
float db = volume01 > 0.001f ? Mathf.Log10(volume01) * 20f : -80f;
masterMixer.SetFloat("MasterVolume", db);
}
// 3D空间音效(在世界位置播放,自动距离衰减)
public void PlaySFX(AudioClip clip, Vector3 worldPos, float volume = 1f) {
AudioSource.PlayClipAtPoint(clip, worldPos, volume);
}
// 背景音乐淡入淡出
public IEnumerator FadeBGM(AudioClip newBGM, float fadeDuration) {
// 淡出
float startVol = bgmSource.volume;
for (float t = 0; t < fadeDuration; t += Time.deltaTime) {
bgmSource.volume = Mathf.Lerp(startVol, 0, t / fadeDuration);
yield return null;
}
bgmSource.clip = newBGM;
bgmSource.Play();
// 淡入
for (float t = 0; t < fadeDuration; t += Time.deltaTime) {
bgmSource.volume = Mathf.Lerp(0, startVol, t / fadeDuration);
yield return null;
}
}
}
10.2 音频压缩格式与加载方式
Load Type(加载方式) 决定音频如何进内存:
| Load Type | 内存占用 | 播放延迟 | 适用 |
|---|---|---|---|
| Decompress On Load | 最大(解压后的PCM) | 无 | 高频小音效(脚步声、击打) |
| Compressed In Memory | 中(压缩存储) | 小 | 中长音效(技能、爬底音效) |
| Streaming | 最小 | 有一些 | BGM、长音频 |
格式选择:
- WAV:无损源文件导入用
- OGG:跳帧小质量好,大多数平台最优
- MP3:兴容性最好,WebGL/iOS硬件解码
csharp
// 按类型配置音频导入设置(Editor脚本)
// BGM配置:自动设置Steaming
using UnityEditor;
[InitializeOnLoad]
public static class AudioImportRules {
static AudioImportRules() {
// 通过 AssetPostprocessor 自动应用导入设置
}
}
public class AudioPostprocessor : AssetPostprocessor {
void OnPreprocessAudio() {
var importer = (AudioImporter)assetImporter;
if (assetPath.Contains("/BGM/")) {
var settings = importer.defaultSampleSettings;
settings.loadType = AudioClipLoadType.Streaming;
settings.compressionFormat = AudioCompressionFormat.Vorbis;
settings.quality = 0.5f; // 50%质量
importer.defaultSampleSettings = settings;
} else if (assetPath.Contains("/SFX/")) {
var settings = importer.defaultSampleSettings;
settings.loadType = AudioClipLoadType.CompressedInMemory;
settings.compressionFormat = AudioCompressionFormat.Vorbis;
settings.quality = 0.7f;
importer.defaultSampleSettings = settings;
}
}
}
10.3 AudioMixer Snapshot------音效状态切换
Snapshot(快照) 记录 AudioMixer 所有参数的一个状态(音量、EQ、Reverb等)。在快照间平滑过渡:
- 正常状态:清晰音效
- 水下状态:低频滤波 + 混响
- 暂停状态:所有音效耗尽 + UI音效如常
csharp
public class AudioStateController : MonoBehaviour {
[SerializeField] AudioMixerSnapshot normalSnapshot;
[SerializeField] AudioMixerSnapshot underwaterSnapshot;
[SerializeField] AudioMixerSnapshot pausedSnapshot;
[SerializeField] float transitionTime = 0.5f;
// 进入水下(0.5秒内平滑过渡到水下音效)
public void EnterWater() => underwaterSnapshot.TransitionTo(transitionTime);
public void ExitWater() => normalSnapshot.TransitionTo(transitionTime);
public void PauseGame() => pausedSnapshot.TransitionTo(0.1f);
public void ResumeGame() => normalSnapshot.TransitionTo(0.1f);
// 多快照混合(根据权重混合多个快照的状态)
void MixSnapshots(float underwaterAmount) {
AudioMixerSnapshot[] snapshots = { normalSnapshot, underwaterSnapshot };
float[] weights = { 1f - underwaterAmount, underwaterAmount };
normalSnapshot.audioMixer.TransitionToSnapshots(snapshots, weights, transitionTime);
}
}
10.4 音效池------处理高频音效
问题: PlayOneShot 虽然可以叠加播放,但无法控制同时最多几个、无法提前切断、也无法复用 AudioSource。音效池预先创建 N 个 AudioSource,轮询分配。
csharp
public class SFXPool : MonoBehaviour {
[SerializeField] int poolSize = 16;
AudioSource[] _sources;
int _index = 0;
void Awake() {
_sources = new AudioSource[poolSize];
for (int i = 0; i < poolSize; i++) {
var go = new GameObject($"SFXSource_{i}");
go.transform.SetParent(transform);
_sources[i] = go.AddComponent<AudioSource>();
_sources[i].playOnAwake = false;
}
}
// 轮询取用(秦 AudioSource最旧的一个)
public void Play(AudioClip clip, Vector3 pos, float volume = 1f) {
var src = _sources[_index % poolSize];
_index++;
src.transform.position = pos;
src.clip = clip;
src.volume = volume;
src.Play();
}
}
Ch11 --- 资源管理基础
11.1 ScriptableObject------数据与逻辑分离
什么是 ScriptableObject? SO 是一种不依附于场景的数据容器,保存为 .asset 文件。它的核心价值是把"数据"从"逻辑"中分离出来:策划直接编辑 SO 配置,程序员只写逻辑,两者互不干扰。
SO vs PlayerPrefs vs JSON 的选择:
ScriptableObject:游戏配置数据(武器属性、关卡参数、技能数据),设计时确定,不需要运行时写入PlayerPrefs:少量简单的用户设置(音量、分辨率),小量键值JSON/Binary:复杂的游戏存档(背包、进度、角色数据),运行时产生
csharp
// 定义武器数据(ScriptableObject)
[CreateAssetMenu(fileName = "NewWeapon", menuName = "Game/Weapon Data")]
public class WeaponData : ScriptableObject {
[Header("基础属性")]
public string weaponName;
public WeaponType type;
[Range(1f, 500f)] public float damage;
[Range(0.1f, 5f)] public float attackSpeed;
public int magazineSize;
[Header("资源引用")]
public GameObject weaponPrefab;
public Sprite icon;
public AudioClip fireSound;
public AudioClip reloadSound;
public ParticleSystem muzzleFlash;
}
// 使用(武器系统直接引用SO)
public class WeaponSystem : MonoBehaviour {
[SerializeField] WeaponData currentWeapon; // Inspector直接拖入SO资产
void Fire() {
// 直接读取SO数据
float dmg = currentWeapon.damage;
AudioSource.PlayClipAtPoint(currentWeapon.fireSound, transform.position);
}
// 运行时切换武器(切换不同的SO)
public void EquipWeapon(WeaponData newWeapon) {
currentWeapon = newWeapon;
RefreshUI();
}
}
11.2 Resources.Load vs Addressables
Resources.Load 的问题:
Resources/目录下的所有资源都会打入包体,即使游戏没用到- 无法热更(资源更新需要重新发包)
- 加载路径是字符串,拼写错误只在运行时才能发现
Addressables 的优势:
- 精确控制哪些资源打包、哪些远程下载
- 支持热更新(资源在服务器上)
- 地址引用,可以在 Inspector 中配置(
AssetReference) - 自动引用计数,正确释放时自动卸载
csharp
// ❌ 不推荐:Resources.Load(了解即可,新项目不用)
GameObject prefab = Resources.Load<GameObject>("Prefabs/Enemy"); // 路径硬编码
// ✅ 推荐:Addressables(新项目标配)
using UnityEngine.AddressableAssets;
// 方式1:地址字符串(灵活)
var handle = Addressables.LoadAssetAsync<GameObject>("Enemy_Warrior");
await handle.Task;
var prefab = handle.Result;
var instance = Instantiate(prefab);
// 方式2:AssetReference(Inspector拖拽,类型安全)
[SerializeField] AssetReference enemyRef;
async void SpawnEnemy() {
var handle = enemyRef.LoadAssetAsync<GameObject>();
await handle.Task;
Instantiate(handle.Result, spawnPos, Quaternion.identity);
}
// ⚠️ 必须手动释放,否则内存泄漏!
void OnDestroy() {
Addressables.ReleaseAsset(handle); // 释放加载的资源
Addressables.ReleaseInstance(go); // 释放 InstantiateAsync 的实例
}
11.3 场景异步加载
LoadSceneAsync 进度读取的特殊性: operation.progress 范围 0~0.9,最后 10%(激活场景)不体现在 progress 中。allowSceneActivation = false 可手动控制激活时机。
Additive 加载: 添加式加载不卸载当前场景,多场景叠加,适合大世界分块加载。
csharp
public class SceneLoader : MonoBehaviour {
[SerializeField] Slider loadingBar;
[SerializeField] TMP_Text percentText;
public IEnumerator LoadSceneWithProgress(string sceneName) {
AsyncOperation op = SceneManager.LoadSceneAsync(sceneName);
op.allowSceneActivation = false; // 加载完不自动激活
while (!op.isDone) {
float progress = Mathf.Clamp01(op.progress / 0.9f);
loadingBar.value = progress;
percentText.SetText("{0}%", (int)(progress * 100));
if (op.progress >= 0.9f) { // 加载完毕,等待玩家
if (Input.anyKeyDown)
op.allowSceneActivation = true; // 手动激活
}
yield return null;
}
}
// Additive 加载(大世界分块)
public IEnumerator LoadChunkAdditive(string chunkScene) {
yield return SceneManager.LoadSceneAsync(chunkScene, LoadSceneMode.Additive);
}
public IEnumerator UnloadChunk(string chunkScene) {
yield return SceneManager.UnloadSceneAsync(chunkScene);
Resources.UnloadUnusedAssets(); // 卸载后清理内存
}
}
11.4 内存泄漏排查------常见场景与修复
Unity 中最常见的 4 类内存泄漏:
renderer.material:每次访问都复制一个新 Material(改用 MaterialPropertyBlock)RenderTexture未 Release:占用显存直到游戏结束- 事件未取消订阅:GC 无法回收持有引用的订阅者对象
new Texture2D频繁创建且未Destroy
csharp
// ❌ 内存泄漏:每次访问 renderer.material 都克隆一个 Material
void WrongWay(Renderer r) => r.material.color = Color.red;
// ✅ 正确:MaterialPropertyBlock 不克隆材质
static readonly int ColorID = Shader.PropertyToID("_Color");
MaterialPropertyBlock _mpb;
void CorrectWay(Renderer r) {
if (_mpb == null) _mpb = new MaterialPropertyBlock();
r.GetPropertyBlock(_mpb);
_mpb.SetColor(ColorID, Color.red);
r.SetPropertyBlock(_mpb);
}
// 场景切换时主动释放
void Start() {
SceneManager.sceneUnloaded += OnSceneUnloaded;
}
void OnSceneUnloaded(Scene s) {
Resources.UnloadUnusedAssets(); // 释放无引用资产
System.GC.Collect(); // 场景切换时可主动 GC(不频繁调用)
}
void OnDestroy() {
SceneManager.sceneUnloaded -= OnSceneUnloaded; // 必须取消订阅!
}