|
// (2025-03-25) |
|
// PlayerScript v2.0 |
|
// https://xra.fanbox.cc/posts/9578926 |
|
|
|
// Options START |
|
|
|
// Apply a look-at rotation to VMC motion. Depending on various situations, the applied rotation can be head+chest (default), head only (when hips motion is on), or off (when VMC tracker is used). Set apply_look_rotation to false if you want to completely disable it. |
|
let apply_look_rotation = true; |
|
|
|
// Options END |
|
|
|
const bone_hierarchy = { |
|
"Hips": { |
|
"Spine": { |
|
"Chest": { |
|
"UpperChest": { |
|
"Neck": { |
|
"Head": { |
|
"LeftEye": {}, |
|
"RightEye": {}, |
|
"Jaw": {} // is it right position? |
|
} |
|
}, |
|
"LeftShoulder": { |
|
"LeftUpperArm": { |
|
"LeftLowerArm": { |
|
"LeftHand": { |
|
"LeftThumbProximal": { |
|
"LeftThumbIntermediate": { |
|
"LeftThumbDistal": {} |
|
} |
|
}, |
|
"LeftIndexProximal": { |
|
"LeftIndexIntermediate": { |
|
"LeftIndexDistal": {} |
|
} |
|
}, |
|
"LeftMiddleProximal": { |
|
"LeftMiddleIntermediate": { |
|
"LeftMiddleDistal": {} |
|
} |
|
}, |
|
"LeftRingProximal": { |
|
"LeftRingIntermediate": { |
|
"LeftRingDistal": {} |
|
} |
|
}, |
|
"LeftLittleProximal": { |
|
"LeftLittleIntermediate": { |
|
"LeftLittleDistal": {} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
}, |
|
"RightShoulder": { |
|
"RightUpperArm": { |
|
"RightLowerArm": { |
|
"RightHand": { |
|
"RightThumbProximal": { |
|
"RightThumbIntermediate": { |
|
"RightThumbDistal": {} |
|
} |
|
}, |
|
"RightIndexProximal": { |
|
"RightIndexIntermediate": { |
|
"RightIndexDistal": {} |
|
} |
|
}, |
|
"RightMiddleProximal": { |
|
"RightMiddleIntermediate": { |
|
"RightMiddleDistal": {} |
|
} |
|
}, |
|
"RightRingProximal": { |
|
"RightRingIntermediate": { |
|
"RightRingDistal": {} |
|
} |
|
}, |
|
"RightLittleProximal": { |
|
"RightLittleIntermediate": { |
|
"RightLittleDistal": {} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
}, |
|
"LeftUpperLeg": { |
|
"LeftLowerLeg": { |
|
"LeftFoot": { |
|
"LeftToes": {} |
|
} |
|
} |
|
}, |
|
"RightUpperLeg": { |
|
"RightLowerLeg": { |
|
"RightFoot": { |
|
"RightToes": {} |
|
} |
|
} |
|
} |
|
} |
|
}; |
|
|
|
let rotation_apply_order = []; |
|
|
|
let bone_rotations = {}; |
|
let bone_positions = {}; // 位置情報を保持する変数 |
|
let apply_hips_position = false; // 初期状態では適用しない |
|
|
|
// 追加:Hip位置変換用の基準(アバターのワールド座標と回転)を保持する変数 |
|
let base_avatar_position = null; |
|
let base_avatar_rotation = null; |
|
|
|
function init_rotation_apply_order(hierarchy) { |
|
for (const bone of Object.keys(hierarchy)) { |
|
rotation_apply_order.push(bone); |
|
init_rotation_apply_order(hierarchy[bone]); |
|
} |
|
} |
|
|
|
// 受信したボーンの位置・回転情報を処理する |
|
function on_receive_vmc_ext_bone_pos(args) { |
|
if (args.length < 8) { |
|
_.log("arguments of /VMC/Ext/Bone/Pos is too short" + |
|
" (8 expected, but " + args.length + ")"); |
|
return; |
|
} |
|
|
|
const bone_name = args[0].getAsciiString(); |
|
const pos_x = args[1].getFloat(); |
|
const pos_y = args[2].getFloat(); |
|
const pos_z = args[3].getFloat(); |
|
const q_x = args[4].getFloat(); |
|
const q_y = args[5].getFloat(); |
|
const q_z = args[6].getFloat(); |
|
const q_w = args[7].getFloat(); |
|
|
|
const rotation = new Quaternion(q_x, q_y, q_z, q_w); |
|
bone_rotations[bone_name] = rotation; |
|
|
|
// Hips の位置を保存(ローカル座標) |
|
// keep leg bones' positions for calculation of hip height |
|
if (bone_name === "Hips" || /left(upperleg|lowerleg|foot|toes)/i.test(bone_name)) { |
|
bone_positions[bone_name] = new Vector3(pos_x, pos_y, pos_z); |
|
} |
|
} |
|
|
|
// 再帰的にボーンの回転を計算する |
|
function get_bone_rotations_from_current_bone(rotations, cur_rotation, bone_hierarchy) { |
|
for (const key of Object.keys(bone_hierarchy)) { |
|
let rotation = cur_rotation.clone(); |
|
if (key in bone_rotations) { |
|
rotation = rotation.multiply(bone_rotations[key]); |
|
if (rotation_offset[key]) |
|
rotation = rotation_offset[key].clone().multiply(rotation); |
|
} |
|
rotations[key] = rotation; |
|
// 再帰呼び出しで子ボーンの回転を計算 |
|
get_bone_rotations_from_current_bone(rotations, rotation, bone_hierarchy[key]); |
|
} |
|
} |
|
|
|
// アバターのルート回転から各ボーンの回転を計算する |
|
function get_bone_rotations_from_root(avatar_rotation) { |
|
let rotations = {}; |
|
const init_rotation = avatar_rotation; // アバターの回転を初期値として使用 |
|
get_bone_rotations_from_current_bone(rotations, init_rotation, bone_hierarchy); |
|
return rotations; |
|
} |
|
|
|
function set_humanoid_pose() { |
|
const avatar_rotation = _.getRotation(); |
|
if (avatar_rotation) { |
|
const bone_rotations_from_root = get_bone_rotations_from_root(avatar_rotation); |
|
for (const key of rotation_apply_order) { |
|
if (!(key in bone_rotations)) continue; |
|
|
|
const rotation = bone_rotations_from_root[key]; |
|
_.setHumanoidBoneRotationOnFrame(HumanoidBone[key], rotation); |
|
} |
|
} |
|
} |
|
|
|
// ローカル座標からワールド座標へ変換する処理 |
|
// ※ここでは「基準」となるアバターの位置と回転(base_avatar_position, base_avatar_rotation)を使う |
|
function convertLocalToWorld(localPos, basePos, baseRot) { |
|
let worldPos = localPos.clone(); |
|
// アバターの回転を適用 |
|
worldPos.applyQuaternion(baseRot); |
|
// アバターのワールド位置を加算 |
|
worldPos.add(basePos); |
|
return worldPos; |
|
} |
|
|
|
let pos_last; |
|
const lower_body_RE = /hips|leg|foot|toes/i; |
|
|
|
// 毎フレーム、アバターの回転および位置を更新する |
|
function update_avatar_transform() { |
|
update_base_avatar_transform(); |
|
|
|
const avatar_rotation = _.getRotation(); |
|
|
|
const full_body_mocap = apply_hips_position && base_avatar_position && base_avatar_rotation && "Hips" in bone_positions; |
|
let skip_hips_mov = !full_body_mocap; |
|
|
|
let upper_body_mocap; |
|
if (skip_hips_mov) { |
|
// use upper body mocap when riding on items |
|
let flags = _.getAvatarMovementFlags(); |
|
let isRiding = (flags & 0x0004) !== 0; |
|
upper_body_mocap = isRiding; |
|
|
|
// skip mocap if avatar is moving (via WASD/Space/etc) |
|
if (!upper_body_mocap) { |
|
const pos_now = _.getPosition(); |
|
if (pos_now && pos_last && !pos_now.equals(pos_last)) { |
|
pos_last = pos_now?.clone(); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
if (avatar_rotation) { |
|
const bone_rotations_from_root = get_bone_rotations_from_root(avatar_rotation); |
|
for (const key of Object.keys(bone_rotations)) { |
|
if (key in HumanoidBone && (!upper_body_mocap || !lower_body_RE.test(key))) { |
|
const rotation = bone_rotations_from_root[key]; |
|
_.setHumanoidBoneRotationOnFrame(HumanoidBone[key], rotation); |
|
} |
|
} |
|
} |
|
|
|
// Hips の位置を適用(Y軸を -0.8 ずらす) |
|
// ※ここで注意:毎フレーム現在のアバターの位置・回転を使うと連鎖的に変換され |
|
// 急激に位置がずれてしまう可能性があるため、Hips位置適用をONにした時点の基準を使用する |
|
if (!skip_hips_mov) { |
|
let localHipsPosition = bone_positions["Hips"].clone(); |
|
adjust_hips_position(localHipsPosition); |
|
// ローカル座標を、Hips適用開始時の基準アバター位置・回転を使ってワールド座標に変換 |
|
let worldHipsPosition = convertLocalToWorld(localHipsPosition, base_avatar_position, base_avatar_rotation); |
|
_.setPosition(worldHipsPosition); |
|
} |
|
|
|
pos_last = _.getPosition()?.clone(); |
|
} |
|
|
|
let hips_height_OSC = 0; |
|
let hips_height_avatar = 0; |
|
|
|
function adjust_hips_position(hips_pos) { |
|
let update_log; |
|
if (!hips_height_OSC) { |
|
const length = {}; |
|
for (const bone_name of ['LeftUpperLeg', 'LeftLowerLeg', 'LeftFoot']) { |
|
const pos = bone_positions[bone_name]; |
|
if (!pos) { |
|
hips_pos.y -= 0.8; |
|
return; |
|
} |
|
length[bone_name] = pos?.length(); |
|
} |
|
|
|
let hips_height = length['LeftUpperLeg']*0.5 + length['LeftLowerLeg'] + length['LeftFoot']*1.2; |
|
hips_height = Math.round(hips_height*1000) / 1000; |
|
|
|
hips_height_OSC = hips_height; |
|
update_log = true; |
|
} |
|
|
|
const bone_pos = {}; |
|
for (const bone_name of ['Hips', 'LeftUpperLeg', 'LeftLowerLeg', 'LeftFoot']) { |
|
const pos = _.getHumanoidBonePosition(HumanoidBone[bone_name]); |
|
if (!pos) { |
|
hips_pos.y -= hips_height_OSC; |
|
return; |
|
} |
|
bone_pos[bone_name] = pos; |
|
} |
|
|
|
let hips_height = bone_pos['Hips'].sub(bone_pos['LeftUpperLeg']).length()*0.5 + bone_pos['LeftUpperLeg'].sub(bone_pos['LeftLowerLeg']).length() + bone_pos['LeftLowerLeg'].sub(bone_pos['LeftFoot']).length()*1.2; |
|
hips_height = Math.round(hips_height*1000) / 1000; |
|
|
|
if (hips_height_avatar !== hips_height) { |
|
hips_height_avatar = hips_height; |
|
update_log = true; |
|
} |
|
|
|
const hips_scale = hips_height_avatar / hips_height_OSC; |
|
hips_pos.multiplyScalar(hips_scale); |
|
hips_pos.y -= hips_height_avatar; |
|
|
|
if (update_log) { |
|
_.log('Hips height (OSC): ' + hips_height_OSC); |
|
_.log('Hips height (Avatar): ' + hips_height_avatar); |
|
_.log('Hips scale: x' + hips_scale); |
|
} |
|
} |
|
|
|
let OSC_enabled = false; |
|
let OSC_initialized = false; |
|
let OSC_active_timestamp = 0; |
|
|
|
let VMC_camera_active_timestamp = 0; |
|
|
|
let apply_hips_position_last = false; |
|
|
|
let VMC_tracker_items; |
|
let VMC_tracker_active_timestamp = 0; |
|
|
|
function check_OSC() { |
|
if (!_.oscHandle.isReceiveEnabled() || OSC_initialized) return; |
|
OSC_initialized = true; |
|
|
|
_.oscHandle.onReceive((messages) => { |
|
for (const msg of messages) { |
|
if (msg.address === "/VMC/Ext/Bone/Pos") { |
|
OSC_active_timestamp = Date.now(); |
|
|
|
on_receive_vmc_ext_bone_pos(msg.values); |
|
} |
|
else if (msg.address === "/VMC/Ext/Cam") { |
|
VMC_camera_active_timestamp = Date.now(); |
|
} |
|
else if (msg.address === "/VMC/Ext/Tra/Pos") { |
|
VMC_tracker_active_timestamp = Date.now(); |
|
|
|
VMC_tracker_items?.receive_message(msg.values); |
|
} |
|
} |
|
}); |
|
|
|
_.log("OSC receiver is enabled, thus onReceive() is activated"); |
|
} |
|
|
|
let rotation_offset = {}; |
|
|
|
// Hips適用開始時点のアバターのワールド座標と回転を基準として保持する |
|
// _.getPosition()/_.getRotation() may return null in some cases |
|
function update_base_avatar_transform() { |
|
// Apply a look-at rotation to VMC motion, based on the default head rotation offset. |
|
rotation_offset = {}; |
|
if (apply_look_rotation && !_.isVr && (VMC_tracker_active_timestamp < Date.now()-1000)) { |
|
const q_head = _.getHumanoidBoneRotation(HumanoidBone.Head); |
|
const q_root = _.getRotation(); |
|
if (q_head && q_root) { |
|
let q_offset = q_head.clone().multiply(q_root.clone().invert()); |
|
const q_offset_half = q_offset.slerp(new Quaternion(0,0,0,1), 0.5); |
|
if (apply_hips_position) { |
|
rotation_offset.Head = rotation_offset.Neck = q_offset_half; |
|
} |
|
else { |
|
rotation_offset.Head = rotation_offset.Chest = q_offset_half; |
|
} |
|
//_.log(rotation_offset.Head.w) |
|
} |
|
} |
|
|
|
if (!apply_hips_position) return; |
|
|
|
let updating; |
|
if (!base_avatar_position) { |
|
base_avatar_position = _.getPosition()?.clone(); |
|
updating = true; |
|
} |
|
if (!base_avatar_rotation) { |
|
base_avatar_rotation = _.getRotation()?.clone() |
|
updating = true; |
|
} |
|
|
|
if (updating && base_avatar_position && base_avatar_rotation) |
|
_.log("Hipsの位置適用: ON"); |
|
} |
|
|
|
// 毎フレーム、位置と回転を更新 |
|
_.onFrame((deltaTime) => { |
|
check_OSC(); |
|
|
|
const time = Date.now(); |
|
let OSC_active = OSC_active_timestamp > time - 1000; |
|
|
|
if (OSC_enabled != OSC_active) { |
|
OSC_enabled = OSC_active; |
|
|
|
// recalculate hips height whenever OSC status changes |
|
hips_height_OSC = 0; |
|
hips_height_avatar = 0; |
|
|
|
if (OSC_enabled) VMC_tracker_items?.init(); |
|
|
|
_.log('OSC enabled: ' + ((OSC_enabled) ? 'ON' : 'OFF')); |
|
} |
|
|
|
let VMC_camera_active = VMC_camera_active_timestamp > time - 1000; |
|
|
|
apply_hips_position = VMC_camera_active; |
|
if (apply_hips_position_last != apply_hips_position) { |
|
apply_hips_position_last = apply_hips_position; |
|
|
|
if (apply_hips_position) { |
|
_.log("Hipsの位置適用: 初期化中"); |
|
} else { |
|
// prevent cases like when avatar may fall through the ground because the last hip y was too low |
|
if (base_avatar_position) _.setPosition(base_avatar_position); |
|
|
|
base_avatar_position = base_avatar_rotation = null; |
|
_.log("Hipsの位置適用: OFF"); |
|
} |
|
} |
|
|
|
if (OSC_enabled) |
|
update_avatar_transform(); |
|
|
|
VMC_tracker_items?.Item.check_active(); |
|
}); |
|
|
|
init_rotation_apply_order(bone_hierarchy); |
|
|
|
|
|
// Math functions |
|
// https://github.com/mrdoob/three.js/blob/master/src/math/Quaternion.js |
|
Quaternion.prototype.slerp = function ( qb, t ) { |
|
|
|
if ( t === 0 ) return this; |
|
if ( t === 1 ) return this.copy( qb ); |
|
|
|
const x = this.x, y = this.y, z = this.z, w = this.w; |
|
|
|
// http://www.euclideanspace.com/maths/algebra/realNormedAlgebra/quaternions/slerp/ |
|
|
|
let cosHalfTheta = w * qb.w + x * qb.x + y * qb.y + z * qb.z; |
|
|
|
if ( cosHalfTheta < 0 ) { |
|
|
|
this.w = - qb.w; |
|
this.x = - qb.x; |
|
this.y = - qb.y; |
|
this.z = - qb.z; |
|
|
|
cosHalfTheta = - cosHalfTheta; |
|
|
|
} else { |
|
|
|
this.copy( qb ); |
|
|
|
} |
|
|
|
if ( cosHalfTheta >= 1.0 ) { |
|
|
|
this.w = w; |
|
this.x = x; |
|
this.y = y; |
|
this.z = z; |
|
|
|
return this; |
|
|
|
} |
|
|
|
const sqrSinHalfTheta = 1.0 - cosHalfTheta * cosHalfTheta; |
|
|
|
if ( sqrSinHalfTheta <= Number.EPSILON ) { |
|
|
|
const s = 1 - t; |
|
this.w = s * w + t * this.w; |
|
this.x = s * x + t * this.x; |
|
this.y = s * y + t * this.y; |
|
this.z = s * z + t * this.z; |
|
|
|
this.normalize(); // normalize calls _onChangeCallback() |
|
|
|
return this; |
|
|
|
} |
|
|
|
const sinHalfTheta = Math.sqrt( sqrSinHalfTheta ); |
|
const halfTheta = Math.atan2( sinHalfTheta, cosHalfTheta ); |
|
const ratioA = Math.sin( ( 1 - t ) * halfTheta ) / sinHalfTheta, |
|
ratioB = Math.sin( t * halfTheta ) / sinHalfTheta; |
|
|
|
this.w = ( w * ratioA + this.w * ratioB ); |
|
this.x = ( x * ratioA + this.x * ratioB ); |
|
this.y = ( y * ratioA + this.y * ratioB ); |
|
this.z = ( z * ratioA + this.z * ratioB ); |
|
|
|
return this; |
|
|
|
}; |
|
|
|
// 追加分 |
|
_.onReceive((messageType, args, sender) => { |
|
switch (messageType) { |
|
case "voiceRate": |
|
for (let element of args.a) { |
|
const playerId = element.a; |
|
const voiceRate = element.b; |
|
_.setVoiceVolumeRateOf(playerId, voiceRate); |
|
} |
|
break; |
|
default: |
|
break; |
|
} |
|
}, { item: true }); |