本文最后更新于140 天前,其中的信息可能已经过时,如有错误请发送邮件到big_fw@foxmail.com
概述
VRM 头部跟踪功能实现了「眼睛注视 + 头/脖子转动」跟踪鼠标光标的效果。系统采用 双通道架构:
- 通道 A(眼睛):高灵敏、低延迟 → 通过
vrm.lookAt.target驱动 - 通道 B(头部):较慢惯性、平滑运动 → 通过 neck/head 骨骼加成旋转驱动
默认始终启用,无 UI 开关。若 VRM 模型不支持相关骨骼 / LookAt 则自动降级。
文件结构
| 文件 | 职责 |
|---|---|
static/vrm-cursor-follow.js | 核心控制器,双通道跟踪逻辑、One-Euro 滤波、骨骼旋转 |
static/vrm-manager.js | 渲染循环集成,调用 updateTarget / applyHead |
static/vrm-core.js | VRM 版本检测(detectVRMVersion),影响前方向判断 |
static/vrm-animation.js | 动画播放系统,提供 isIdleAnimation 标志供权重判断 |
static/vrm-interaction.js | 交互系统(拖拽/hover),提供 isDragging 供权重判断 |
核心类:CursorFollowController
生命周期
init(vrmManager)
→ 每帧 updateTarget(delta) + applyHead(delta)
→ reset()(切换模型)
→ destroy()
初始化流程(init)
- 创建
eyesTarget(THREE.Object3D),加入场景 - 计算初始位置:头部世界坐标 + 相机方向 ×
lookAtDistance - 预分配所有临时 THREE.js 对象(减少 GC 压力)
- 创建 4 个 One-Euro 滤波器(眼睛 X/Y,头部 Yaw/Pitch)
- 绑定
pointermove事件 - 调用
_detectModelForward()检测模型前方向
关键设计决策
1. 预分配临时对象(零 GC)
所有 Vector3、Quaternion、Euler 等临时对象都在 init() 中预分配,帧循环中复用:
this._tempVec3A = new THREE.Vector3();
this._tempVec3B = new THREE.Vector3();
this._tempVec3C = new THREE.Vector3();
this._tempVec3D = new THREE.Vector3();
this._tempQuat = new THREE.Quaternion();
this._tempQuatB = new THREE.Quaternion();
// ... 共 5 个 Quaternion、4 个 Vector3、1 个 Euler
2. _hasPointerInput 首帧保护
鼠标坐标初始为 (0, 0),如果不加保护,首帧会计算出朝向屏幕左上角的方向。通过 _hasPointerInput 标志,在首次 pointermove 事件触发前跳过 updateTarget:
_bindEvents() {
this._onPointerMove = (e) => {
this._rawMouseX = e.clientX;
this._rawMouseY = e.clientY;
this._hasPointerInput = true; // 首次 pointermove 后才开始驱动
};
}
updateTarget(delta) {
if (!this._hasPointerInput) return; // 首帧保护
// ...
}
3. 骨骼基准姿态快照(防漂移)
每帧先快照动画姿态,恢复后再叠加旋转,避免 premultiply 累加漂移:
// 快照
if (neckBone) this._neckBaseQuat.copy(neckBone.quaternion);
if (headBone) this._headBaseQuat.copy(headBone.quaternion);
// ... 计算 yaw/pitch ...
// 恢复基准 → 叠加
neckBone.quaternion.copy(this._neckBaseQuat);
this._applyAdditiveRotation(neckBone, sceneQuat, yaw * neckContrib * w, pitch * neckContrib * w);
通道 A:眼睛跟踪(updateTarget)
流程
鼠标屏幕坐标 → NDC → One-Euro 滤波 → 射线与头部平面求交 → 指数阻尼平滑 → eyesTarget.position
关键代码
updateTarget(delta) {
// ① NDC 转换
const rawNdcX = ((this._rawMouseX - rect.left) / rect.width) * 2 - 1;
const rawNdcY = -((this._rawMouseY - rect.top) / rect.height) * 2 + 1;
// ② One-Euro 滤波(NDC 层面)
const filteredX = this._eyeFilterX.filter(rawNdcX, this._elapsedTime);
const filteredY = this._eyeFilterY.filter(rawNdcY, this._elapsedTime);
// ③ 射线与通过头部的平面求交
this._raycaster.setFromCamera(this._ndcVec.set(filteredX, filteredY), camera);
camera.getWorldDirection(this._planeNormal).negate();
this._plane.setFromNormalAndCoplanarPoint(this._planeNormal, headPos);
const hit = this._raycaster.ray.intersectPlane(this._plane, this._tempVec3A);
// ④ 指数阻尼平滑
const eyeAlpha = 1 - Math.exp(-delta * D.eyeSmoothSpeed);
this.eyesTarget.position.lerp(this._desiredTargetPos, eyeAlpha);
}
眼睛注视由 vrm.lookAt.target 驱动(在渲染循环第 3 步设置):
// vrm-manager.js 渲染循环
this.currentModel.vrm.lookAt.target = this._cursorFollow.eyesTarget;
通道 B:头/颈旋转(applyHead)
流程
eyesTarget 世界坐标 - 头部世界坐标
→ 模型坐标系分解(dx,dy,dz)
→ atan2
→ One-Euro 滤波
→ Clamp
→ 指数阻尼
→ 构造偏移四元数
→ 空间变换
→ premultiply
VRM 版本前方向适配
不同 VRM 版本的模型空间前方向不同:
| 模型类型 | 模型空间前方向 | _modelForwardZ |
|---|---|---|
| VRM 1.0 | three-vrm 内部 180° Y 翻转 | -1 |
| VRM 0.x | 原始方向 | +1 |
通过 _detectModelForward() 基于模型元数据版本判断:
_detectModelForward() {
const vrmVersion = this.manager?.core?.vrmVersion;
// VRM 1.0: three-vrm 内部已翻转 scene,forwardSign = -1
// VRM 0.x: forwardSign = +1
this._modelForwardZ = (vrmVersion === '1.0') ? -1 : 1;
}
版本检测由 vrm-core.js 的 detectVRMVersion(vrm, gltf) 提供,检测优先级:
- GLTF extensionsUsed:
VRMC_vrm→ 1.0,VRM→ 0.x(最可靠) - vrm.meta 属性:
authors(数组)→ 1.0,author(字符串)→ 0.x - 启发式:骨骼数 > 50 或表情数 > 10 → 1.0
方向分解与 yaw/pitch 计算
// 模型坐标系构建(使用 _modelForwardZ 适配不同版本)
const modelForward = this._tempVec3B.set(0, 0, this._modelForwardZ).applyQuaternion(sceneQuat);
const modelUp = this._tempVec3C.set(0, 1, 0).applyQuaternion(sceneQuat);
const modelRight = this._tempVec3D.crossVectors(modelUp, modelForward).normalize();
// 分解
const dx = dirWorld.dot(modelRight);
const dy = dirWorld.dot(modelUp);
const dz = dirWorld.dot(modelForward);
// yaw: 负 dx 是因为 Three.js 右手坐标系正 Y 旋转 = 逆时针
let rawYaw = Math.atan2(-dx, Math.max(dz, 0.001));
let rawPitch = Math.atan2(dy, Math.max(horizLen, 0.001));
加成旋转空间变换(_applyAdditiveRotation)
模型空间偏移(YXZ Euler → Quaternion)
↓ sceneQuat * offset * sceneQuat⁻¹
世界空间偏移
↓ parentQuat⁻¹ * worldOffset * parentQuat
骨骼父级本地空间偏移
↓ bone.quaternion.premultiply(localOffset)
最终叠加
_applyAdditiveRotation(bone, sceneWorldQuat, yaw, pitch) {
// 模型空间偏移四元数(先 yaw 后 pitch → YXZ)
this._tempEuler.set(pitch, yaw, 0, 'YXZ');
const modelOffset = this._tempQuatB.setFromEuler(this._tempEuler);
// 模型空间 → 世界空间
const sceneQuatInv = this._tempQuatC.copy(sceneWorldQuat).invert();
const worldOffset = this._tempQuatD
.copy(sceneWorldQuat).multiply(modelOffset).multiply(sceneQuatInv);
// 世界空间 → 骨骼父级本地空间
const parentQuat = this._tempQuatE;
bone.parent ? bone.parent.getWorldQuaternion(parentQuat) : parentQuat.identity();
const parentQuatInv = this._tempQuatC.copy(parentQuat).invert();
const localOffset = parentQuatInv.multiply(worldOffset).multiply(parentQuat);
// premultiply 叠加
bone.quaternion.premultiply(localOffset);
}
One-Euro 滤波器
自适应噪声滤波器,在平滑度和跟手性之间自动权衡:
- 静止时:低截止频率 → 强平滑 → 消除抖动
- 快速运动时:高截止频率 → 弱平滑 → 快速跟手
class OneEuroFilter {
filter(x, t) {
// 导数(速度估计)
const dx = (x - this._xPrev) / te;
const dxHat = ad * dx + (1 - ad) * this._dxPrev; // 低通滤波
// 自适应截止频率:速度越大 → cutoff 越高 → 越跟手
const cutoff = this.minCutoff + this.beta * Math.abs(dxHat);
const a = this._alpha(te, cutoff);
// 低通滤波
return a * x + (1 - a) * this._xPrev;
}
}
参数对照:
| 参数 | 眼睛通道 | 头部通道 | 说明 |
|---|---|---|---|
| minCutoff | 1.5 | 0.8 | 最小截止频率(越大越跟手) |
| beta | 0.5 | 0.3 | 速度系数(越大快动越跟手) |
| dCutoff | 1.0 | 1.0 | 导数截止频率 |
动画感知权重系统
头部跟踪的权重根据当前状态动态调整:
| 状态 | 权重 | 说明 |
|---|---|---|
| 纯静止 | 1.0 | 完全跟踪 |
| 待机动画 | 0.7 | 加成叠加,保留呼吸协调 |
| 拖拽中 | 0.15 | 大幅降低 |
| 一次性动作 | 0.0 | 完全让位给动画 |
权重通过指数阻尼平滑过渡(weightTransitionSec: 0.2):
_updateHeadWeight(delta) {
if (this._isActionPlaying()) {
this._targetHeadWeight = 0.0; // 一次性动作
} else if (this._isDragging()) {
this._targetHeadWeight = 0.15; // 拖拽
} else if (this._isIdleAnimPlaying()) {
this._targetHeadWeight = 0.7; // 待机动画
} else {
this._targetHeadWeight = 1.0; // 纯静止
}
const alpha = 1 - Math.exp(-delta * speed);
this._headWeight += (this._targetHeadWeight - this._headWeight) * alpha;
}
待机 vs 一次性动作判断
- 待机动画:
animation.isIdleAnimation === true(由playVRMAAnimation的isIdle参数显式标记) - 一次性动作:
vrmaIsPlaying && !isIdleAnimation && currentAction.isRunning()
头/颈分配
总旋转量按比例分配给颈和头:
neckContribution: 0.6 // 脖子承担 60%
headContribution: 0.4 // 头部承担 40%
渲染循环集成顺序(vrm-manager.js)
1. 表情更新
2. CursorFollow.updateTarget(delta) ← 眼睛目标位置更新
3. 设置 vrm.lookAt.target ← 连接 eyesTarget
4. 动画更新 (mixer.update)
5. vrm.update(delta) ← LookAt / SpringBone 物理
6. CursorFollow.applyHead(delta) ← 头/颈加成旋转(必须在 vrm.update 之后)
7. 交互系统更新
8. 渲染器渲染
第 6 步必须在第 5 步之后:vrm.update 会重置骨骼变换(包括 autoUpdateHumanBones),头/颈加成旋转必须在之后叠加。
模型切换时的 reset 流程
// vrm-manager.js loadModel 中
const result = await this.core.loadModel(modelUrl, options);
if (this._cursorFollow) {
this._cursorFollow.reset();
}
reset() 执行:
- 清零 yaw/pitch/权重/计时
- 重置所有 One-Euro 滤波器
- 重新检测模型前方向(
_detectModelForward()) - 重置眼睛目标到头部前方
默认参数总览
const CURSOR_FOLLOW_DEFAULTS = Object.freeze({
eyeMaxYawDeg: 30, eyeMaxPitchUpDeg: 18, eyeMaxPitchDownDeg: 14,
eyeSmoothSpeed: 12.0, eyeOneEuroMinCutoff: 1.5, eyeOneEuroBeta: 0.5,
headMaxYawDeg: 20, headMaxPitchUpDeg: 12, headMaxPitchDownDeg: 10,
headSmoothSpeed: 5.0, headOneEuroMinCutoff: 0.8, headOneEuroBeta: 0.3,
neckContribution: 0.6, headContribution: 0.4,
headWeightIdle: 1.0, headWeightIdleAnim: 0.7, headWeightAction: 0.0,
weightTransitionSec: 0.2,
lookAtDistance: 2.4,
});
资源清理(destroy)
销毁时完整清理:
重置标志位
移除 pointermove 事件监听
从场景移除 eyesTarget
释放所有预分配 THREE.js 对象(置 null)
释放所有 One-Euro 滤波器









