VRM 头部跟踪功能实现
本文最后更新于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.jsVRM 版本检测(detectVRMVersion),影响前方向判断
static/vrm-animation.js动画播放系统,提供 isIdleAnimation 标志供权重判断
static/vrm-interaction.js交互系统(拖拽/hover),提供 isDragging 供权重判断

核心类:CursorFollowController

生命周期

init(vrmManager)
  → 每帧 updateTarget(delta) + applyHead(delta)
  → reset()(切换模型)
  → destroy()

初始化流程(init

  1. 创建 eyesTargetTHREE.Object3D),加入场景
  2. 计算初始位置:头部世界坐标 + 相机方向 × lookAtDistance
  3. 预分配所有临时 THREE.js 对象(减少 GC 压力)
  4. 创建 4 个 One-Euro 滤波器(眼睛 X/Y,头部 Yaw/Pitch)
  5. 绑定 pointermove 事件
  6. 调用 _detectModelForward() 检测模型前方向

关键设计决策

1. 预分配临时对象(零 GC)

所有 Vector3QuaternionEuler 等临时对象都在 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.0three-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.jsdetectVRMVersion(vrm, gltf) 提供,检测优先级:

  1. GLTF extensionsUsedVRMC_vrm → 1.0,VRM → 0.x(最可靠)
  2. vrm.meta 属性authors(数组)→ 1.0,author(字符串)→ 0.x
  3. 启发式:骨骼数 > 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;
    }
}

参数对照:

参数眼睛通道头部通道说明
minCutoff1.50.8最小截止频率(越大越跟手)
beta0.50.3速度系数(越大快动越跟手)
dCutoff1.01.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(由 playVRMAAnimationisIdle 参数显式标记)
  • 一次性动作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() 执行:

  1. 清零 yaw/pitch/权重/计时
  2. 重置所有 One-Euro 滤波器
  3. 重新检测模型前方向_detectModelForward()
  4. 重置眼睛目标到头部前方

默认参数总览

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 滤波器

就这样喵~

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇