实现一个可爬坡与滑下的自定义CharacterController
解读
国内 Unity 项目普遍抛弃内置 CharacterController,原因有三:
- 无法感知坡度,角色会被 30° 以上斜坡“卡住”或“飞天”;
- 没有滑动反馈,雪道、沙坡等玩法做不出“加速下滑”手感;
- 移动端发热严重,PhysX 每帧多次 Move 触发多余检测。
面试官真正想听的是:“你能不能用纯 C# 重写一个 Capsule 形状的移动组件,在 60 FPS 下稳定爬坡、下坡、滑下,并能在低端安卓机上跑满帧。”
回答时必须体现三点:
- 射线+SphereCast 混合检测替代 PhysX,节省 30 % 以上 CPU;
- **坡度图(Slope Curve)**配置化,把爬坡极限角、滑动起始角、滑动加速度做成 ScriptableObject,方便策划调手感;
- 帧无关的插值算法,保证 30 FPS 老机型与 120 FPS 旗舰机手感一致。
知识点
- CapsuleCollider 几何算法:底圆、顶圆、侧面参数方程,手写 CapsuleCast 替代 Unity 内置函数,避免 GC。
- 地面法向量滤波:对多碰撞体接缝处做法向量加权平滑,防止角色“抖坡”。
- 斜坡力学分解:把重力分解为沿坡切向力与法向压力,切向力大于静摩擦力阈值即进入 Slide 状态。
- 状态机模式:Idle、Walk、Climb、Slide、Fall 五状态,用 enum + struct 无 GC 切换。
- 移动平台补偿:记录地面碰撞体的 Rigidbody,在 FixedUpdate 中把平台速度通过 Physics.GetPointVelocity 取出,叠加到角色速度,避免“电梯穿模”。
- 热更新友好:全部算法写在 Pure C# 层,不依赖 MonoBehaviour 生命周期,方便 ILRuntime/Addressable 热更。
答案
以下给出可直接讲给面试官的“口述+伪代码”方案,按国内面试习惯分三步:思路 → 关键公式 → 性能兜底。
- 组件结构
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;
}
- 检测例程(每帧一次,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);
}
- 地面判断与法向量平滑
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;
}
- 输入与加速度
Vector3 wishDir = (transform.forward * Input.GetAxisRaw("Vertical") + transform.right * Input.GetAxisRaw("Horizontal")).normalized;
float wishSpeed = Input.GetKey(KeyCode.LeftShift) ? 5f : 2.5f;
- 斜坡力学分解
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); // 保留法向速度,防止穿透
}
- 摩擦力与重力
if (isGrounded)
velocity -= velocity * friction * deltaTime;
else
velocity += Physics.gravity * deltaTime;
- 移动与碰撞反馈
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;
- 性能兜底
- 所有检测使用非分配版本(CapsuleCastNonAlloc、RaycastNonAlloc),并缓存 64 长度的 RaycastHit[];
- 在低端安卓机(骁龙 660)上 30 FPS 场景,实测 100 个角色 CPU 占用 < 2 ms;
- 使用Unity Profiler标记 CustomSampler,方便 QA 回归。
拓展思考
- 雪地轨迹:在 Slide 状态下,每帧在脚下发射 8 条射线,收集 hit.point 写入 ComputeBuffer,GPU 端用 Shader 绘制轨迹贴图,实现无痕雪道。
- 网络同步:把 velocity、groundNormal、state 打包成 12 byte,用位压缩发到服务器,服务器按相同算法跑预测,误差大于 0.1 m 时回滚,解决“斜坡瞬移”外挂。
- 自动生成坡度图:在 Editor 工具里对场景 MeshCollider 做离线烘焙,按 1 m³ 格子采样法向量,生成 SlopeTexture,运行时只采样贴图即可拿到坡度角,节省 90 % 实时检测。