Files
opendance-web/web-server/src/preprocess/mmd-pose.ts
T
zijunchiang bba5bcf59e feat(web): 端口分离 + VMD 动作支持;契约升至 0.2.0
需求 1:管理端端口与软件 API 端口分离
- 软件 API 端口 PORT(默认 3000)只提供 /api、/health、/static、/contracts,
  不提供管理页面(未命中路径 404);管理端端口 ADMIN_PORT(默认 3001)
  提供管理页面与全部接口;ADMIN_HOST 可单独限制管理端来源
- main.ts 同进程监听两个端口,用 req.socket.localPort 区分端口行为;
  ADMIN_PORT=PORT 为单端口兼容模式(只创建一个 server,无 EADDRINUSE)
- 端口非法在生产环境中止启动;ADMIN_PORT=PORT 只 warn
- 启动日志分别打印两个端口的访问地址

需求 2:动作数据支持 VMD(MikuMikuDance)
- vmd-parser.ts:二进制解析(30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼
  关键帧 + morph 段)、MMD 贝塞尔插值、GBK 模型名回退、固定 30fps
- mmd-skeleton.ts:标准 MMD 骨架;静止比例从参考 PMX 骨骼段实测得到
  (足首 1.43 / 膝 6.57 / 髋 11.50 / 首 16.21 / 頭 17.16,腿长 10.09)
- mmd-pose.ts:FK + 两骨骼解析腿部 IK(膝盖朝正面,不可达收敛为伸直)
  + 左手系→右手系 Z 镜像(由真实数据对照实验确定:94.1% vs 5.9%)
- retarget.ts:抽出 worldFramesToKeypointFrames(),BVH 与 VMD 共用重定向
- MotionFormat 增 vmd、扩展名映射增 .vmd;管理端上传支持 .vmd
- scripts/inspect-vmd.mjs:上传前自查工具

契约:rest-api / data-models / model-formats → 0.2.0(另两库已同步逐字节一致)

验证:85 项单测通过(VMD 31 项);契约与 29 条路由一致;typecheck/build 通过;
真实样本经 HTTP 端到端:霜雪千年 2901 帧(IK 腿)、恋爱循环 4604 帧(FK 腿)
均 preprocessState=done、0 NaN、0 越界(测试数据已从数据库清理)。
2026-09-15 08:47:37 +08:00

275 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Mat3, Transform, Vec3, applyTransform, matVec, quatToMat3, vAdd, vLen, vNorm, vScale, vSub } from './math3d';
import { LEG_IK, LEG_LENGTHS, MMD_SKELETON, MmdBoneDef } from './mmd-skeleton';
import { VmdMotion, VmdBoneTrack, sampleBoneTrack, trackHasPositionMotion } from './vmd-parser';
/**
* VMD 数值的解释方式。
*
* MMD 使用左手坐标系,而本项目的重定向通路(与 BVH 一致)是右手系。
* 两种解释只有在真实数据上跑一遍才能判断哪种是"物理正确"的:
* `zMirrored` 相当于把动画在 Z 轴上做一次镜像(旋转四元数取
* (x,y,z,w) → (-x,-y,z,w),位移 z 取反)。
* 见 mmd-vmd.spec.ts 中基于真实 VMD 的"膝盖朝前"对照实验。
*/
export type MmdRotationMode = 'asIs' | 'zMirrored';
export interface MmdPoseOptions {
rotationMode?: MmdRotationMode;
/** 采样帧率,默认 VMD 固定的 30 */
fps?: number;
/** 最大采样帧数,用于防御异常文件(默认 30000 帧 ≈ 16 分钟) */
maxFrames?: number;
/** 是否启用腿部 IK 解算(默认自动判断;诊断与测试可强制关闭) */
legIk?: boolean;
}
/** 单帧解算结果:boneName(标准名)→ 世界变换 */
export type Pose = Map<string, Transform>;
const IDENTITY_TRANSFORM: Transform = { r: [1, 0, 0, 0, 1, 0, 0, 0, 1], t: [0, 0, 0] };
/** S·R·S,S = diag(1,1,-1):把左手系旋转搬到右手系 */
function mirrorRotationZ(r: Mat3): Mat3 {
return [
r[0], r[1], -r[2],
r[3], r[4], -r[5],
-r[6], -r[7], r[8],
];
}
function convertRotation(
quat: readonly [number, number, number, number],
mode: MmdRotationMode,
): Mat3 {
const matrix = quatToMat3(quat);
return mode === 'zMirrored' ? mirrorRotationZ(matrix) : matrix;
}
function convertPosition(position: Vec3, mode: MmdRotationMode): Vec3 {
return mode === 'zMirrored' ? [position[0], position[1], -position[2]] : [...position] as Vec3;
}
/** 骨架的拓扑顺序(父骨骼一定排在子骨骼之前) */
const SKELETON_ORDER: readonly MmdBoneDef[] = (() => {
const remaining = [...MMD_SKELETON];
const ordered: MmdBoneDef[] = [];
const done = new Set<string>();
while (remaining.length > 0) {
const index = remaining.findIndex((bone) => bone.parent === null || done.has(bone.parent));
if (index < 0) throw new Error('MMD 标准骨架存在环,无法拓扑排序');
const [bone] = remaining.splice(index, 1);
ordered.push(bone);
done.add(bone.name);
}
return ordered;
})();
/**
* 解算单帧:标准骨架 FK + 腿部 IK。
*
* MMD 的位移通道是**世界轴**上的偏移(不被父骨骼旋转带走),
* 因此平移单独叠加:`t = parentT + parentR * restOffset + animPos`。
*/
export function solveFrame(
motion: VmdMotion,
frame: number,
mode: MmdRotationMode,
useLegIk: { 左: boolean; 右: boolean },
): Pose {
const pose: Pose = new Map();
for (const bone of SKELETON_ORDER) {
const parent = bone.parent ? pose.get(bone.parent) ?? IDENTITY_TRANSFORM : IDENTITY_TRANSFORM;
const track: VmdBoneTrack | undefined = motion.tracks.get(bone.name);
const sampled = track ? sampleBoneTrack(track, frame) : null;
const localR = sampled
? convertRotation(sampled.rotation, mode)
: IDENTITY_TRANSFORM.r;
const animPos = sampled ? convertPosition(sampled.position, mode) : ([0, 0, 0] as Vec3);
pose.set(bone.name, {
r: applyTransform(parent, bone.offset, localR).r,
t: vAdd(vAdd(parent.t, matVec(parent.r, bone.offset)), animPos),
});
}
for (const side of ['左', '右'] as const) {
if (!useLegIk[side]) continue;
solveLegIk(pose, side);
}
return pose;
}
/**
* 两骨骼解析 IK:把小腿末端拉到 IK 目标点,膝盖朝 +Z(角色正面)弯。
* 目标超出可达范围时按比例收敛到伸直/极限,不产生 NaN。
*/
function solveLegIk(pose: Pose, side: '左' | '右'): void {
const names = LEG_IK[side];
const hip = pose.get(`${side}足`);
const ik = pose.get(names.ik);
if (!hip || !ik) return;
const upper = LEG_LENGTHS.thigh;
const lower = LEG_LENGTHS.shin;
const hipPos = hip.t;
const target = ik.t;
let delta = vSub(target, hipPos);
let distance = vLen(delta);
const minReach = Math.abs(upper - lower) + 1e-4;
const maxReach = upper + lower - 1e-4;
if (distance < 1e-6) {
// 退化:目标与髋重合,直接落地
delta = [0, -1, 0];
distance = 1;
}
const axis = vNorm(delta);
let ankle = target;
if (distance > maxReach) {
ankle = vAdd(hipPos, vScale(axis, maxReach));
distance = maxReach;
} else if (distance < minReach) {
ankle = vAdd(hipPos, vScale(axis, minReach));
distance = minReach;
}
// 髋到膝在髋-踝连线上的投影长度
const along = (upper * upper - lower * lower + distance * distance) / (2 * distance);
const heightSq = Math.max(0, upper * upper - along * along);
const height = Math.sqrt(heightSq);
// 膝盖参考方向:优先 +Z(正面),与连线正交化;退化时退回 +Y
const forward: Vec3 = [0, 0, 1];
let perp = vSub(forward, vScale(axis, forward[0] * axis[0] + forward[1] * axis[1] + forward[2] * axis[2]));
if (vLen(perp) < 1e-4) {
const up: Vec3 = [0, 1, 0];
perp = vSub(up, vScale(axis, up[0] * axis[0] + up[1] * axis[1] + up[2] * axis[2]));
}
perp = vNorm(perp);
const kneePos = vAdd(vAdd(hipPos, vScale(axis, along)), vScale(perp, height));
const thighDir = vNorm(vSub(kneePos, hipPos));
const shinDir = vNorm(vSub(ankle, kneePos));
const knee = pose.get(names.knee);
const ankleBone = pose.get(names.ankle);
if (knee) {
knee.t = kneePos;
if (vLen(thighDir) > 1e-6) {
// 让膝的旋转与"指向踝"的方向一致,供下游(如趾尖)使用
knee.r = alignRotation(knee.r, thighDir);
}
}
if (ankleBone) {
ankleBone.t = ankle;
// MMD 把 IK 骨骼的旋转直接赋给足首
ankleBone.r = ik.r;
if (vLen(shinDir) > 1e-6) ankleBone.r = alignRotation(ik.r, shinDir);
}
const toe = pose.get(names.toe);
if (toe && ankleBone) {
const restOffset: Vec3 = [0, -0.2, LEG_LENGTHS.toe > 0 ? 1.3 : 1.3];
toe.t = vAdd(ankle, matVec(ankleBone.r, restOffset));
toe.r = ankleBone.r;
}
}
/**
* 让骨骼的 Y 轴(MMD 骨骼默认朝向子骨骼的方向)对齐给定方向,
* 保留绕该方向的扭转分量。用于 IK 解算后给出合理的局部朝向。
*/
function alignRotation(base: Mat3, direction: Vec3): Mat3 {
const dir = vNorm(direction);
if (vLen(dir) < 1e-6) return base;
// 取一个与 dir 不平行的参考轴构造正交基(列优先:X, Y=dir, Z)
const reference: Vec3 = Math.abs(dir[1]) > 0.9 ? [1, 0, 0] : [0, 1, 0];
const xAxis = vNorm([
reference[1] * dir[2] - reference[2] * dir[1],
reference[2] * dir[0] - reference[0] * dir[2],
reference[0] * dir[1] - reference[1] * dir[0],
]);
if (vLen(xAxis) < 1e-6) return base;
const zAxis: Vec3 = [
xAxis[1] * dir[2] - xAxis[2] * dir[1],
xAxis[2] * dir[0] - xAxis[0] * dir[2],
xAxis[0] * dir[1] - xAxis[1] * dir[0],
];
return [
xAxis[0], dir[0], zAxis[0],
xAxis[1], dir[1], zAxis[1],
xAxis[2], dir[2], zAxis[2],
];
}
/** 关节映射表使用的骨骼名(与 joint-map.ts 的候选名一致) */
const HEAD_KEY = 'head';
const JOINT_KEYS: ReadonlyArray<{ key: string; bone: string; offset?: Vec3 }> = [
{ key: HEAD_KEY, bone: '頭', offset: [0, 0.6, 0] },
{ key: 'leftarm', bone: '左腕' },
{ key: 'rightarm', bone: '右腕' },
{ key: 'leftforearm', bone: '左ひじ' },
{ key: 'rightforearm', bone: '右ひじ' },
{ key: 'lefthand', bone: '左手首' },
{ key: 'righthand', bone: '右手首' },
{ key: 'leftupleg', bone: '左足' },
{ key: 'rightupleg', bone: '右足' },
{ key: 'leftleg', bone: '左ひざ' },
{ key: 'rightleg', bone: '右ひざ' },
{ key: 'leftfoot', bone: '左足首' },
{ key: 'rightfoot', bone: '右足首' },
{ key: 'lefttoebase', bone: '左つま先' },
{ key: 'righttoebase', bone: '右つま先' },
];
/** 把一帧的姿态转成重定向模块使用的"骨骼名 → 世界坐标"表 */
function poseToWorld(pose: Pose): Map<string, Vec3> {
const world = new Map<string, Vec3>();
for (const { key, bone, offset } of JOINT_KEYS) {
const transform = pose.get(bone);
if (!transform) continue;
world.set(key, offset ? vAdd(transform.t, offset) : transform.t);
}
return world;
}
/**
* VMD → 逐帧世界坐标(键名与 `joint-map.ts` 的候选名一致)。
* 输出可直接交给 `worldFramesToKeypointFrames` 复用 BVH 通路的重定向。
*/
export function vmdToWorldFrames(
motion: VmdMotion,
options: MmdPoseOptions = {},
): { frames: Map<string, Vec3>[]; fps: number } {
const mode = options.rotationMode ?? 'zMirrored';
const fps = options.fps && options.fps > 0 ? options.fps : 30;
const maxFrames = options.maxFrames ?? 30000;
const frameCount = Math.min(motion.maxFrame + 1, maxFrames);
// 腿部是否由 IK 驱动:IK 目标有位移 → 用 IK;否则用 FK 旋转(部分动作数据是烘焙好的 FK)
const autoIk = {
左: trackHasPositionMotion(motion.tracks.get(LEG_IK.左.ik)),
右: trackHasPositionMotion(motion.tracks.get(LEG_IK.右.ik)),
};
const useLegIk =
options.legIk === false ? { 左: false, 右: false } : autoIk;
const frames: Map<string, Vec3>[] = [];
for (let frame = 0; frame < frameCount; frame += 1) {
frames.push(poseToWorld(solveFrame(motion, frame, mode, useLegIk)));
}
return { frames, fps };
}
/** 诊断用:返回本帧被 IK 驱动的腿(测试与日志使用) */
export function legIkDriven(motion: VmdMotion): { 左: boolean; 右: boolean } {
return {
左: trackHasPositionMotion(motion.tracks.get(LEG_IK.左.ik)),
右: trackHasPositionMotion(motion.tracks.get(LEG_IK.右.ik)),
};
}