vrm-springbone-physics

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

VRM SpringBone Physics Debugging

VRM SpringBone 物理效果调试

This skill covers common issues with VRM hair/clothing physics using
@pixiv/three-vrm
and how to fix them.
本内容介绍了使用
@pixiv/three-vrm
时VRM头发/衣物物理效果的常见问题及修复方法。

Common Symptoms

常见症状

  1. Hair flies upward or explodes outward on load
  2. Hair sticks out horizontally like there's an invisible wall
  3. Hair is stiff and doesn't move naturally
  4. Physics works but starts from wrong position

  1. 头发在加载时向上飞起或向外炸开
  2. 头发水平突出,仿佛被无形的墙挡住
  3. 头发僵硬,无法自然摆动
  4. 物理效果正常,但起始位置错误

Root Cause 1: Incorrect Delta Time (Most Common - 90%)

根因1:Delta Time设置错误(最常见 - 占90%)

Problem

问题

The
vrm.update(delta)
function expects delta in seconds, not milliseconds. If delta is too large, physics "explodes".
vrm.update(delta)
函数要求delta参数为,而非毫秒。如果delta值过大,物理效果会“炸开”。

Diagnosis

诊断方法

javascript
// Add this to your animation loop
console.log('delta:', delta);
// Should be ~0.016 for 60fps, NOT 16 or larger!
javascript
// 添加到动画循环中
console.log('delta:', delta);
// 60fps下应约为0.016,而非16或更大值!

Solution

解决方案

javascript
// Correct implementation using THREE.Clock
const clock = new THREE.Clock();

function animate() {
    requestAnimationFrame(animate);
    
    let delta = clock.getDelta();
    // Clamp to prevent explosion on tab switch or lag
    delta = Math.min(delta, 0.05);  // Max 50ms
    
    if (vrm) {
        vrm.update(delta);
    }
    
    renderer.render(scene, camera);
}

javascript
// 使用THREE.Clock的正确实现
const clock = new THREE.Clock();

function animate() {
    requestAnimationFrame(animate);
    
    let delta = clock.getDelta();
    // 限制最大值,防止切换标签或卡顿导致物理异常
    delta = Math.min(delta, 0.05);  // 最大50ms
    
    if (vrm) {
        vrm.update(delta);
    }
    
    renderer.render(scene, camera);
}

Root Cause 2: SpringBone Colliders (Very Common)

根因2:SpringBone碰撞体(非常常见)

Problem

问题

VRM models have invisible spherical colliders (usually on head/body) that prevent hair from penetrating. Virtually ALL VRM models have oversized colliders, causing hair to appear stuck horizontally in mid-air.
VRM模型带有不可见的球形碰撞体(通常在头部/身体),用于防止头发穿透模型。几乎所有VRM模型的碰撞体都过大,导致头发看起来水平悬浮在空中。

Root Cause Analysis

根因分析

Confirmed Facts

已确认事实

  1. UniVRM Export Bug Exists (#673):
    • When colliders are on scaled objects, the radius doesn't normalize with the mesh
    • Gizmo shows correct size in editor, but exported collider is larger
    • Issue documented with reproducible steps
  2. three-vrm Uses Radius Directly (source):
    typescript
    const distance = length - objectRadius - this.radius;  // radius not scaled by world matrix
  3. UniVRM Officially Discourages Scaling (source):
    "We do not recommend using SpringBone and scaling together"
  1. UniVRM导出存在Bug#673):
    • 当碰撞体位于缩放后的对象上时,半径不会随网格进行归一化
    • 编辑器中的Gizmo显示尺寸正确,但导出后的碰撞体更大
    • 该问题已记录,且有可复现步骤
  2. three-vrm直接使用半径值源码):
    typescript
    const distance = length - objectRadius - this.radius;  // 半径未按世界矩阵缩放
  3. UniVRM官方不建议同时使用SpringBone和缩放源码):
    "我们不建议同时使用SpringBone和缩放功能"

Empirical Observation

经验观察

[!NOTE] 50% reduction fixes ALL tested models. The exact mathematical reason is uncertain - the export scaling could vary by model/tool. However, this factor works universally in practice.
Possible explanations:
  • VRoid Studio (most common VRM source) may use consistent internal scaling
  • The visual matching in Unity editor may systematically create ~2x overcorrection
  • Export normalization algorithms may have consistent behavior
[!NOTE] 将半径缩小50%可修复所有测试过的模型。具体数学原因尚不明确——导出缩放比例可能因模型/工具而异。但在实践中,该系数普遍有效。
可能的解释:
  • VRoid Studio(最常用的VRM制作工具)可能使用一致的内部缩放比例
  • Unity编辑器中的视觉匹配可能系统性地产生约2倍的过度校正
  • 导出归一化算法可能存在一致的行为

Practical Approach

实用解决思路

Since the exact cause varies, we provide an adjustable reduction factor with 50% as default.
由于确切原因各不相同,我们提供一个可调整的缩小系数,默认值为50%。

Diagnosis

诊断方法

Check if only bangs are horizontal (collider issue) or all physics elements (gravity issue):
  • Only bangs horizontal → Head collider blocking them
  • All physics horizontal → Gravity direction wrong
Disable colliders to confirm:
javascript
const colliders = Array.from(springBoneManager.colliders || []);
colliders.forEach(c => {
    if (c.shape?.radius) c.shape.radius = 0;
});
// If hair now falls correctly, colliders were the issue
检查是只有刘海水平突出(碰撞体问题)还是所有物理元素都异常(重力问题):
  • 仅刘海水平突出 → 头部碰撞体阻挡
  • 所有物理元素水平 → 重力方向错误
禁用碰撞体以确认:
javascript
const colliders = Array.from(springBoneManager.colliders || []);
colliders.forEach(c => {
    if (c.shape?.radius) c.shape.radius = 0;
});
// 如果头发现在能正常下落,则问题出在碰撞体

Solutions

解决方案

Option 1: Reduce Collider Radii by 50% (Recommended - Compensates for export bug)
javascript
const REDUCTION_FACTOR = 0.5;  // Compensates for UniVRM export scaling bug
const colliders = Array.from(springBoneManager.colliders || []);
colliders.forEach(collider => {
    if (collider.shape?.radius > 0) {
        // Save original for potential future adjustment
        if (collider._originalRadius === undefined) {
            collider._originalRadius = collider.shape.radius;
        }
        collider.shape.radius = collider._originalRadius * REDUCTION_FACTOR;
    }
});
Option 2: Completely Disable Colliders (Simple but may cause clipping)
javascript
const colliders = Array.from(springBoneManager.colliders || []);
colliders.forEach(collider => {
    if (collider.shape?.radius !== undefined) {
        collider.shape.radius = 0;
    }
});
Option 3: Disable Only Head Colliders (Best, needs bone name detection)
javascript
colliders.forEach(collider => {
    const boneName = collider.bone?.name?.toLowerCase() || '';
    if (boneName.includes('head') || boneName.includes('face')) {
        collider.shape.radius = 0;
    }
});
Option 4: Fix in Unity (Permanent fix, requires model access)
  1. Open model in Unity with VRM SDK
  2. Find "secondary" object in hierarchy
  3. Select head bone with
    VRMSpringBoneColliderGroup
  4. Enable gizmos to see magenta collider spheres
  5. Reduce radius/adjust offset to proper size
  6. Re-export VRM
Option 5: Scale Colliders with Scene (Runtime fix for scaled models)
When
vrm.scene.scale
is changed at runtime, colliders need to be scaled proportionally:
javascript
function scaleVRMScene(vrm, scaleFactor) {
    // Scale the scene
    vrm.scene.scale.setScalar(scaleFactor);
    
    // Scale all collider radii to match
    const springBoneManager = vrm.springBoneManager;
    if (springBoneManager) {
        const colliders = Array.from(springBoneManager.colliders || []);
        colliders.forEach(collider => {
            if (collider.shape?.radius !== undefined) {
                // Store original radius if not already stored
                if (collider._originalRadius === undefined) {
                    collider._originalRadius = collider.shape.radius;
                }
                // Scale radius with scene
                collider.shape.radius = collider._originalRadius * scaleFactor;
            }
        });
    }
}

方案1:将碰撞体半径缩小50%(推荐 - 补偿导出Bug)
javascript
const REDUCTION_FACTOR = 0.5;  // 补偿UniVRM导出缩放Bug
const colliders = Array.from(springBoneManager.colliders || []);
colliders.forEach(collider => {
    if (collider.shape?.radius > 0) {
        // 保存原始值以便后续调整
        if (collider._originalRadius === undefined) {
            collider._originalRadius = collider.shape.radius;
        }
        collider.shape.radius = collider._originalRadius * REDUCTION_FACTOR;
    }
});
方案2:完全禁用碰撞体(简单但可能导致穿透)
javascript
const colliders = Array.from(springBoneManager.colliders || []);
colliders.forEach(collider => {
    if (collider.shape?.radius !== undefined) {
        collider.shape.radius = 0;
    }
});
方案3:仅禁用头部碰撞体(最佳方案,需检测骨骼名称)
javascript
colliders.forEach(collider => {
    const boneName = collider.bone?.name?.toLowerCase() || '';
    if (boneName.includes('head') || boneName.includes('face')) {
        collider.shape.radius = 0;
    }
});
方案4:在Unity中修复(永久修复,需访问模型文件)
  1. 使用VRM SDK在Unity中打开模型
  2. 在层级面板中找到“secondary”对象
  3. 选择带有
    VRMSpringBoneColliderGroup
    的头部骨骼
  4. 启用Gizmos查看品红色的碰撞体球体
  5. 缩小半径/调整偏移至合适尺寸
  6. 重新导出VRM
方案5:随场景缩放碰撞体(针对缩放模型的运行时修复)
当在运行时修改
vrm.scene.scale
时,碰撞体半径也需要按比例缩放:
javascript
function scaleVRMScene(vrm, scaleFactor) {
    // 缩放场景
    vrm.scene.scale.setScalar(scaleFactor);
    
    // 按比例缩放所有碰撞体半径
    const springBoneManager = vrm.springBoneManager;
    if (springBoneManager) {
        const colliders = Array.from(springBoneManager.colliders || []);
        colliders.forEach(collider => {
            if (collider.shape?.radius !== undefined) {
                // 若未保存原始半径则进行保存
                if (collider._originalRadius === undefined) {
                    collider._originalRadius = collider.shape.radius;
                }
                // 随场景缩放半径
                collider.shape.radius = collider._originalRadius * scaleFactor;
            }
        });
    }
}

Root Cause 2B: Runtime Scene Scaling (Application-Specific)

根因2B:运行时场景缩放(特定应用场景)

Problem

问题

If your application scales
vrm.scene
to fit different screen sizes, the collider radii remain fixed in local space while bones scale with the scene. This causes colliders to become relatively larger when the model is scaled down.
如果你的应用程序缩放
vrm.scene
以适配不同屏幕尺寸,碰撞体半径会保持局部空间的固定值,而骨骼会随场景缩放。这会导致当模型缩小时,碰撞体相对变大

Example

示例

  • Model scaled to 0.8x (80% size)
  • Head collider radius stays at original 0.1 units
  • Relative to the scaled head, the collider is now 0.1/0.8 = 0.125 (25% larger)
  • Hair that previously cleared the collider now gets blocked
  • 模型缩放到0.8倍(原尺寸的80%)
  • 头部碰撞体半径保持原始的0.1单位
  • 相对于缩放后的头部,碰撞体现在为0.1/0.8 = 0.125(比原来大25%)
  • 之前能避开碰撞体的头发现在会被挡住

Key Insight

关键见解

VRChat works because it doesn't scale the VRM scene directly - it places the model inside a container and scales the container, or uses a different physics implementation that accounts for scale.
VRChat能正常工作是因为它不直接缩放VRM场景——它将模型放置在容器中并缩放容器,或者使用了能处理缩放的不同物理实现。

Solution

解决方案

When scaling the VRM scene, also scale the collider radii proportionally (see Option 5 above).

缩放VRM场景时,同时按比例缩放碰撞体半径(见上方方案5)。

Root Cause 3: Model Issues

根因3:模型配置问题

Symptoms

症状

  • _worldSpaceBoneLength: 0
    in console logs
  • Hair bones don't respond to physics changes
  • 控制台日志中出现
    _worldSpaceBoneLength: 0
  • 头发骨骼对物理效果修改无响应

Cause

原因

Model was not properly configured in Unity/Blender:
  • Hair bones missing child bones
  • SpringBone settings incorrectly exported
  • Gravity direction wrong in model
模型在Unity/Blender中未正确配置:
  • 头发骨骼缺少子骨骼
  • SpringBone设置导出错误
  • 模型中的重力方向错误

Solution

解决方案

  1. Test with official VRM viewer - if hair is broken there, it's a model issue
  2. Fix in Unity with VRM SDK or Blender with VRM addon
  3. Ensure each hair bone has a proper child bone with non-zero length

  1. 使用官方VRM查看器测试——如果头发在其中也异常,则是模型问题
  2. 使用VRM SDK在Unity中修复,或使用VRM插件在Blender中修复
  3. 确保每个头发骨骼都有长度非零的子骨骼

Recommended Initialization Code

推荐初始化代码

[!CAUTION] Empirical Fix Notice: The
COLLIDER_REDUCTION = 0.5
value is empirically determined from testing multiple VRM models. While the underlying UniVRM bug is documented, we cannot mathematically prove 50% is correct for all models. If you encounter hair physics issues, adjust this value first.
javascript
function initializeVRMPhysics(vrm) {
    const springBoneManager = vrm.springBoneManager;
    if (!springBoneManager) return;
    
    // Reduce collider radii to compensate for UniVRM export bug (#673)
    // This is an EMPIRICAL fix - adjust if needed
    const COLLIDER_REDUCTION = 0.5;
    
    const colliders = Array.from(springBoneManager.colliders || []);
    colliders.forEach(collider => {
        if (collider.shape?.radius > 0) {
            collider._originalRadius = collider.shape.radius;
            collider.shape.radius *= COLLIDER_REDUCTION;
        }
    });
    
    console.log(`[VRM] Applied ${COLLIDER_REDUCTION * 100}% collider reduction to ${colliders.length} colliders`);
}

// Animation loop with delta clamping
const clock = new THREE.Clock();
function animate() {
    requestAnimationFrame(animate);
    
    let delta = clock.getDelta();
    delta = Math.min(delta, 0.05);  // Prevent physics explosion
    
    if (vrm) {
        vrm.update(delta);
    }
    
    renderer.render(scene, camera);
}

[!CAUTION] 经验修复说明
COLLIDER_REDUCTION = 0.5
这个值是通过测试多个VRM模型得出的经验值。虽然UniVRM的Bug已被记录,但我们无法从数学上证明50%对所有模型都适用。如果遇到头发物理效果问题,请先调整该值。
javascript
function initializeVRMPhysics(vrm) {
    const springBoneManager = vrm.springBoneManager;
    if (!springBoneManager) return;
    
    // 缩小碰撞体半径以补偿UniVRM导出Bug (#673)
    // 这是经验性修复 - 如有需要请调整
    const COLLIDER_REDUCTION = 0.5;
    
    const colliders = Array.from(springBoneManager.colliders || []);
    colliders.forEach(collider => {
        if (collider.shape?.radius > 0) {
            collider._originalRadius = collider.shape.radius;
            collider.shape.radius *= COLLIDER_REDUCTION;
        }
    });
    
    console.log(`[VRM] 已对${colliders.length}个碰撞体应用${COLLIDER_REDUCTION * 100}%的半径缩小`);
}

// 带有Delta值限制的动画循环
const clock = new THREE.Clock();
function animate() {
    requestAnimationFrame(animate);
    
    let delta = clock.getDelta();
    delta = Math.min(delta, 0.05);  // 防止物理效果炸开
    
    if (vrm) {
        vrm.update(delta);
    }
    
    renderer.render(scene, camera);
}

Key API Reference

关键API参考

MethodPurpose
springBoneManager.reset()
Clear physics state, return to initial positions
springBoneManager.setInitState()
Capture current position as new "rest" state
springBoneManager.joints
Set of all SpringBone joints
springBoneManager.colliders
Set of all colliders
vrm.update(delta)
Update all VRM systems including physics
方法用途
springBoneManager.reset()
清除物理状态,恢复到初始位置
springBoneManager.setInitState()
将当前位置设为新的“静止”状态
springBoneManager.joints
所有SpringBone关节的集合
springBoneManager.colliders
所有碰撞体的集合
vrm.update(delta)
更新所有VRM系统,包括物理效果

Joint Settings (per joint.settings)

关节设置(每个joint.settings)

PropertyDescription
stiffness
Spring force (0 = no spring, 1 = stiff)
gravityPower
Gravity strength
gravityDir
Vector3 gravity direction (usually 0, -1, 0)
dragForce
Damping (0 = no drag, 1 = full stop)
属性描述
stiffness
弹簧力度(0=无弹簧效果,1=完全僵硬)
gravityPower
重力强度
gravityDir
Vector3类型的重力方向(通常为0, -1, 0)
dragForce
阻尼效果(0=无阻尼,1=完全停止)