第二篇:Unity中级阶段(核心开发能力)

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 优先于 AverageMaximum 优先于 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 通过接口实现自定义事件响应,不需要子类继承。常用接口:

  • IPointerClickHandlerIPointerEnterHandlerIPointerExitHandler
  • IDragHandlerIBeginDragHandlerIEndDragHandler
  • IDropHandler:目标结束拖拽后接受拖拽物

防止 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:持续状态(isRunningisGrounded
  • Float:数值驱动(speeddirection
  • Trigger:一次性触发(jumpattackdie),用完自动重置
  • 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 的问题:

  1. Resources/ 目录下的所有资源都会打入包体,即使游戏没用到
  2. 无法热更(资源更新需要重新发包)
  3. 加载路径是字符串,拼写错误只在运行时才能发现

Addressables 的优势:

  1. 精确控制哪些资源打包、哪些远程下载
  2. 支持热更新(资源在服务器上)
  3. 地址引用,可以在 Inspector 中配置(AssetReference
  4. 自动引用计数,正确释放时自动卸载
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 类内存泄漏:

  1. renderer.material:每次访问都复制一个新 Material(改用 MaterialPropertyBlock)
  2. RenderTexture 未 Release:占用显存直到游戏结束
  3. 事件未取消订阅:GC 无法回收持有引用的订阅者对象
  4. 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; // 必须取消订阅!
}
相关推荐
DaLiangChen8 小时前
Unity 实用工具:动态绘制物体边界包围盒(支持屏幕固定线宽)
unity·游戏引擎
张老师带你学8 小时前
Unity 食物 农产品相关
科技·游戏·unity·游戏引擎·模型
mxwin8 小时前
Unity Custom Interpolators与半透明阴影的原理与实战
unity·游戏引擎·shader
晴夏。8 小时前
UE5第三人称模板实现及相关引擎源码分析
unity·ue5·游戏引擎·ue
HAPPY酷9 小时前
解决 Unreal Engine 编译报错 MSB4018:三个核心排查方向
游戏引擎·虚幻
晴夏。12 小时前
UE原生MovementBase实现分析
游戏引擎·ue·3c
天人合一peng13 小时前
Unity工程发布hololens需安装, MRTK安装
unity·游戏引擎·hololens
weixin_4093831214 小时前
godot 调用class方法得用实例 不能用脚本引用
游戏引擎·godot
风酥糖14 小时前
Godot游戏练习01-第32节-国际化
游戏·游戏引擎·godot