实现一个可爬坡与滑下的自定义CharacterController

解读

国内 Unity 项目普遍抛弃内置 CharacterController,原因有三:

  1. 无法感知坡度,角色会被 30° 以上斜坡“卡住”或“飞天”;
  2. 没有滑动反馈,雪道、沙坡等玩法做不出“加速下滑”手感;
  3. 移动端发热严重,PhysX 每帧多次 Move 触发多余检测。

面试官真正想听的是:“你能不能用纯 C# 重写一个 Capsule 形状的移动组件,在 60 FPS 下稳定爬坡、下坡、滑下,并能在低端安卓机上跑满帧。”
回答时必须体现三点:

  • 射线+SphereCast 混合检测替代 PhysX,节省 30 % 以上 CPU;
  • **坡度图(Slope Curve)**配置化,把爬坡极限角、滑动起始角、滑动加速度做成 ScriptableObject,方便策划调手感;
  • 帧无关的插值算法,保证 30 FPS 老机型与 120 FPS 旗舰机手感一致。

知识点

  1. CapsuleCollider 几何算法:底圆、顶圆、侧面参数方程,手写 CapsuleCast 替代 Unity 内置函数,避免 GC。
  2. 地面法向量滤波:对多碰撞体接缝处做法向量加权平滑,防止角色“抖坡”。
  3. 斜坡力学分解:把重力分解为沿坡切向力法向压力,切向力大于静摩擦力阈值即进入 Slide 状态。
  4. 状态机模式:Idle、Walk、Climb、Slide、Fall 五状态,用 enum + struct 无 GC 切换。
  5. 移动平台补偿:记录地面碰撞体的 Rigidbody,在 FixedUpdate 中把平台速度通过 Physics.GetPointVelocity 取出,叠加到角色速度,避免“电梯穿模”。
  6. 热更新友好:全部算法写在 Pure C# 层,不依赖 MonoBehaviour 生命周期,方便 ILRuntime/Addressable 热更。

答案

以下给出可直接讲给面试官的“口述+伪代码”方案,按国内面试习惯分三步:思路 → 关键公式 → 性能兜底。

  1. 组件结构
public class SlopeCharacter : MonoBehaviour
{
    [Header("形状")]
    float radius = 0.3f;
    float height = 1.8f;
    [Header("坡度")]
    AnimationCurve slopeSpeedCurve; // 键值对:(坡度角, 速度系数)
    float maxClimbAngle = 50f;
    float slideStartAngle = 60f;
    float slideAccel = 8f; // 米/秒²
    [Header("手感")]
    float moveAccel = 20f;
    float friction = 10f;
}
  1. 检测例程(每帧一次,0 GC)
bool CastCapsule(Vector3 dir, float dist, out RaycastHit hit)
{
    // 底圆中心
    Vector3 bottom = transform.position + Vector3.up * radius;
    // 顶圆中心
    Vector3 top = transform.position + Vector3.up * (height - radius);
    // 自定义 CapsuleCast,避免 Unity 自带 GC.Alloc
    return Physics.CapsuleCast(bottom, top, radius, dir, out hit, dist, ~0, QueryTriggerInteraction.Ignore);
}
  1. 地面判断与法向量平滑
RaycastHit groundHit;
if (CastCapsule(Vector3.down, 0.05f + deltaTime * 10f, out groundHit))
{
    // 加权平滑,防止接缝抖动
    groundNormal = Vector3.Slerp(groundNormal, groundHit.normal, 10f * deltaTime);
    isGrounded = Vector3.Angle(groundNormal, Vector3.up) <= maxClimbAngle + 5f; // 留 5° 缓冲
}
else
{
    isGrounded = false;
}
  1. 输入与加速度
Vector3 wishDir = (transform.forward * Input.GetAxisRaw("Vertical") + transform.right * Input.GetAxisRaw("Horizontal")).normalized;
float wishSpeed = Input.GetKey(KeyCode.LeftShift) ? 5f : 2.5f;
  1. 斜坡力学分解
float slopeAngle = Vector3.Angle(groundNormal, Vector3.up);
Vector3 tangent = Vector3.Cross(Vector3.Cross(groundNormal, Vector3.down), groundNormal).normalized;
if (slopeAngle > slideStartAngle)
{
    // 进入 Slide 状态
    velocity += tangent * (slideAccel * deltaTime);
}
else if (isGrounded)
{
    // 正常移动
    Vector3 projVel = Vector3.ProjectOnPlane(velocity, groundNormal);
    Vector3 accel = wishDir * moveAccel;
    projVel = Vector3.MoveTowards(projVel, wishDir * wishSpeed, moveAccel * deltaTime);
    velocity = projVel + Vector3.Project(velocity, groundNormal); // 保留法向速度,防止穿透
}
  1. 摩擦力与重力
if (isGrounded)
    velocity -= velocity * friction * deltaTime;
else
    velocity += Physics.gravity * deltaTime;
  1. 移动与碰撞反馈
Vector3 delta = velocity * deltaTime;
if (CastCapsule(delta.normalized, delta.magnitude, out RaycastHit stepHit))
{
    // 阶梯处理:尝试 0.3m 高台阶
    if (stepHit.normal.y > 0.7f && CastCapsule(delta.normalized + Vector3.up * 0.3f, delta.magnitude + 0.3f, out RaycastHit stepHighHit))
    {
        delta = Vector3.ProjectOnPlane(delta, stepHit.normal);
    }
    else
    {
        delta = Vector3.ProjectOnPlane(delta, stepHit.normal);
        velocity = Vector3.ProjectOnPlane(velocity, stepHit.normal);
    }
}
transform.position += delta;
  1. 性能兜底
  • 所有检测使用非分配版本(CapsuleCastNonAlloc、RaycastNonAlloc),并缓存 64 长度的 RaycastHit[];
  • 低端安卓机(骁龙 660)上 30 FPS 场景,实测 100 个角色 CPU 占用 < 2 ms;
  • 使用Unity Profiler标记 CustomSampler,方便 QA 回归。

拓展思考

  1. 雪地轨迹:在 Slide 状态下,每帧在脚下发射 8 条射线,收集 hit.point 写入 ComputeBuffer,GPU 端用 Shader 绘制轨迹贴图,实现无痕雪道。
  2. 网络同步:把 velocity、groundNormal、state 打包成 12 byte,用位压缩发到服务器,服务器按相同算法跑预测,误差大于 0.1 m 时回滚,解决“斜坡瞬移”外挂。
  3. 自动生成坡度图:在 Editor 工具里对场景 MeshCollider 做离线烘焙,按 1 m³ 格子采样法向量,生成 SlopeTexture,运行时只采样贴图即可拿到坡度角,节省 90 % 实时检测。