29 · 角色移动、追踪与平滑转向
需求与运动模型
角色沿地面以每秒 2 个世界单位走向目标,到达后停止;身体最多每秒旋转 180°。位置更新和旋转更新是两个问题,不能用同一个固定插值系数代替速度。
令当前位置为 p,目标为 q,距离 d = |q − p|,单位方向 u = (q − p) / d。本帧移动量为:
min 防止跨过目标。dt 使用秒,而不是毫秒。接近零距离时没有可靠方向,应停止归一化和朝向更新。示例约定角色本地 +Z 是前方,+Y 是上方。
完整核心代码
import { createDemo } from './runtime.js';
const { THREE, scene, onFrame } = createDemo();
const actor = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(0.6, 1, 0.8).translate(0, 0.5, 0),
new THREE.MeshNormalMaterial()
);
actor.add(body);
actor.add(new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0), 1));
scene.add(actor, new THREE.GridHelper(20, 20));
const target = new THREE.Vector3(4, 0, -3);
const marker = new THREE.Mesh(new THREE.SphereGeometry(0.15), new THREE.MeshBasicMaterial({ color: 0xff6644 }));
marker.position.copy(target);
scene.add(marker);
const direction = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
const targetRotation = new THREE.Quaternion();
const speed = 2;
const angularSpeed = Math.PI;
onFrame((dt) => {
direction.subVectors(target, actor.position);
direction.y = 0;
const distance = direction.length();
if (distance < 1e-5) return;
direction.divideScalar(distance);
actor.position.addScaledVector(direction, Math.min(speed * dt, distance));
const yaw = Math.atan2(direction.x, direction.z);
targetRotation.setFromAxisAngle(up, yaw);
actor.quaternion.rotateTowards(targetRotation, angularSpeed * dt);
});
这里角色直接挂在 scene 下,位置采用世界坐标。带变换父节点时,应先在世界空间计算新位置,再 parent.worldToLocal();目标世界四元数也应转换为父空间。存在非均匀缩放和剪切时,简单四元数转换不足以完整描述变换,建议用不带非均匀缩放的独立运动根节点承载角色。
跟随与插值不能混为一谈
匀速追踪适合有明确移动速度的角色。摄像机缓慢追上目标更适合指数平滑:
例如 actor.position.lerp(target, 1 - Math.exp(-5 * dt))。在目标静止时,这种形式对时间步划分一致;它渐近到达目标,通常需要距离阈值,不保证恒定速度。每帧固定 0.1 则会随刷新率改变结果。
当前代码允许角色侧移一小段,同时身体慢慢转向。车辆不能侧移时,应按身体当前前向移动,而不是直接按目标方向移动,并加入转弯半径或转向模型。
验证与练习(暂不提供答案)
验证不同刷新率下的到达时间;目标正好等于当前位置时不应出现 NaN;将目标放到角色身后,观察最短方向转向。
- 用键盘输入控制角色,防止斜向移动比单轴更快。
- 把模型前向改成 −Z,保持控制行为正确。
- 让目标持续移动,对比匀速追踪和指数平滑。
- 加入带旋转父组,仍以世界速度每秒 2 单位移动。