Skip to content

Commit ad59378

Browse files
committed
SecondLife公式のアニメーションファイルでVRMのアバターが正常にアニメーションされるところまで確認。
1 parent ad60f11 commit ad59378

2 files changed

Lines changed: 170 additions & 28 deletions

File tree

PLAN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ v0.8で確定した変換済み骨格を前提に、BVHの読込とプレビュ
3838
- ボーン名マッピング(SL BVH骨格 ↔ 変換後GLB骨格)を実装
3939
- プレビューでの再生テスト(walk / stand / sit)を実施
4040
- CC BY 3.0に基づく著作権表記の明記
41+
- 今後課題: SLの挙動に寄せたレイヤー合成を実装(例: 歩行中は下半身をwalk系、上半身は腕振りを含む上半身モーションを重ねる)。性別メタデータがある場合は歩行・待機モーションを自動選択し、優先度と適用ボーン範囲を明示的に管理する
4142

4243
### v1.0(次のバージョン):尾・耳・羽の動作実装
4344

frontend/src/components/VrmPreview.vue

Lines changed: 169 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ const canvasHost = ref<HTMLDivElement | null>(null);
1717
const errorMessage = ref('');
1818
const loading = ref(false);
1919
const animationEnabled = ref(false);
20-
const animationStatus = ref('待機モーションは無効です');
20+
const animationStatus = ref('モーションは無効です');
21+
22+
type MotionMode = 'idle' | 'walk';
23+
type AvatarGender = 'female' | 'male' | 'unknown';
24+
25+
const selectedMotionMode = ref<MotionMode>('idle');
26+
const avatarGender = ref<AvatarGender>('unknown');
27+
const currentMotionPath = ref('/animations/avatar_stand_1.bvh');
2128
2229
let scene: THREE.Scene | null = null;
2330
let camera: THREE.PerspectiveCamera | null = null;
@@ -28,9 +35,14 @@ let resizeObserver: ResizeObserver | null = null;
2835
let animationFrameId = 0;
2936
let reloadTimer: ReturnType<typeof setTimeout> | null = null;
3037
let clock: THREE.Clock | null = null;
31-
let bvhIdleClip: THREE.AnimationClip | null = null;
38+
let bvhMotionClip: THREE.AnimationClip | null = null;
3239
let mixer: THREE.AnimationMixer | null = null;
33-
let activeActions: THREE.AnimationAction[] = [];
40+
const bvhClipCache: Map<string, THREE.AnimationClip> = new Map();
41+
42+
const MOTION_MODE_ITEMS = [
43+
{ title: '待機', value: 'idle' as MotionMode },
44+
{ title: '歩行', value: 'walk' as MotionMode }
45+
];
3446
3547
const BVH_TO_SL_BONE: Record<string, string> = {
3648
hip: 'mPelvis',
@@ -65,6 +77,93 @@ const HAND_PROBLEM_BONES = new Set([
6577
'mWristRight'
6678
]);
6779
80+
const parseGlbJsonChunk = (bytes: Uint8Array): Record<string, unknown> | null => {
81+
if (bytes.length < 20) {
82+
return null;
83+
}
84+
85+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
86+
const magic = view.getUint32(0, true);
87+
// ASCII "glTF" in little-endian.
88+
if (magic !== 0x46546c67) {
89+
return null;
90+
}
91+
92+
const jsonChunkLength = view.getUint32(12, true);
93+
const jsonChunkType = view.getUint32(16, true);
94+
// JSON chunk type ASCII "JSON" in little-endian.
95+
if (jsonChunkType !== 0x4e4f534a || 20 + jsonChunkLength > bytes.length) {
96+
return null;
97+
}
98+
99+
const jsonBytes = bytes.slice(20, 20 + jsonChunkLength);
100+
const decoder = new TextDecoder();
101+
try {
102+
return JSON.parse(decoder.decode(jsonBytes)) as Record<string, unknown>;
103+
} catch {
104+
return null;
105+
}
106+
};
107+
108+
const detectGenderFromVrm = async (path: string): Promise<AvatarGender> => {
109+
if (!path) {
110+
return 'unknown';
111+
}
112+
113+
try {
114+
const bytes = await readFile(path);
115+
const json = parseGlbJsonChunk(bytes);
116+
if (!json) {
117+
return 'unknown';
118+
}
119+
120+
const extensions = (json.extensions ?? {}) as Record<string, unknown>;
121+
const vrm0Meta =
122+
((extensions.VRM as Record<string, unknown> | undefined)?.meta as
123+
| Record<string, unknown>
124+
| undefined) ?? {};
125+
const vrm1Meta =
126+
((extensions.VRMC_vrm as Record<string, unknown> | undefined)?.meta as
127+
| Record<string, unknown>
128+
| undefined) ?? {};
129+
130+
const raw =
131+
(vrm0Meta.sex as string | undefined) ??
132+
(vrm0Meta.gender as string | undefined) ??
133+
(vrm1Meta.sex as string | undefined) ??
134+
(vrm1Meta.gender as string | undefined) ??
135+
'';
136+
const value = raw.toLowerCase();
137+
138+
if (value.includes('female') || value.includes('woman') || value.includes('girl')) {
139+
return 'female';
140+
}
141+
if (value.includes('male') || value.includes('man') || value.includes('boy')) {
142+
return 'male';
143+
}
144+
} catch {
145+
// Fall through to unknown when metadata cannot be parsed.
146+
}
147+
148+
return 'unknown';
149+
};
150+
151+
const resolveMotionPath = (mode: MotionMode, gender: AvatarGender): string => {
152+
if (mode === 'walk') {
153+
if (gender === 'female') {
154+
return '/animations/avatar_female_walk.bvh';
155+
}
156+
return '/animations/avatar_walk.bvh';
157+
}
158+
159+
// Use multi-frame stand so preview clearly animates.
160+
return '/animations/avatar_stand_1.bvh';
161+
};
162+
163+
const applyMotionSelection = () => {
164+
currentMotionPath.value = resolveMotionPath(selectedMotionMode.value, avatarGender.value);
165+
};
166+
68167
const updateRendererSize = () => {
69168
if (!canvasHost.value || !renderer || !camera) {
70169
return;
@@ -84,8 +183,6 @@ const clearModel = () => {
84183
mixer.uncacheRoot(mixer.getRoot());
85184
mixer = null;
86185
}
87-
activeActions = [];
88-
89186
if (!scene || !modelRoot) {
90187
return;
91188
}
@@ -143,7 +240,7 @@ const ensureEyeMaterialsVisible = (root: THREE.Object3D) => {
143240
}
144241
145242
if (isEyeSurface) {
146-
material.alphaTest = 0.0;
243+
material.alphaTest = 0;
147244
material.transparent = true;
148245
material.depthWrite = false;
149246
// Keep normal depth test so overall mesh ordering stays natural.
@@ -202,13 +299,13 @@ const parseBvhTrack = (trackName: string): { bone: string; property: string } |
202299
};
203300
204301
const buildRetargetedClip = (targetSkeleton: THREE.Skeleton): THREE.AnimationClip | null => {
205-
if (!bvhIdleClip) {
302+
if (!bvhMotionClip) {
206303
return null;
207304
}
208305
209306
const tracks: THREE.KeyframeTrack[] = [];
210307
211-
for (const track of bvhIdleClip.tracks) {
308+
for (const track of bvhMotionClip.tracks) {
212309
const parsed = parseBvhTrack(track.name);
213310
if (!parsed) {
214311
continue;
@@ -246,16 +343,16 @@ const buildRetargetedClip = (targetSkeleton: THREE.Skeleton): THREE.AnimationCli
246343
return null;
247344
}
248345
249-
return new THREE.AnimationClip('avatar_stand_retargeted', bvhIdleClip.duration, tracks);
346+
return new THREE.AnimationClip('avatar_motion_retargeted', bvhMotionClip.duration, tracks);
250347
};
251348
252349
const applyIdleAnimation = () => {
253350
if (!modelRoot || !animationEnabled.value) {
254351
return;
255352
}
256353
257-
if (!bvhIdleClip) {
258-
animationStatus.value = '待機モーション読み込み待ちです';
354+
if (!bvhMotionClip) {
355+
animationStatus.value = 'モーション読み込み待ちです';
259356
return;
260357
}
261358
@@ -269,8 +366,6 @@ const applyIdleAnimation = () => {
269366
mixer.stopAllAction();
270367
mixer.uncacheRoot(mixer.getRoot());
271368
}
272-
activeActions = [];
273-
274369
mixer = new THREE.AnimationMixer(modelRoot);
275370
276371
let appliedMeshCount = 0;
@@ -294,7 +389,6 @@ const applyIdleAnimation = () => {
294389
action.clampWhenFinished = false;
295390
action.enabled = true;
296391
action.play();
297-
activeActions.push(action);
298392
}
299393
300394
if (appliedMeshCount === 0) {
@@ -320,24 +414,31 @@ const stopIdleAnimation = () => {
320414
mixer.stopAllAction();
321415
mixer.setTime(0);
322416
mixer = null;
323-
activeActions = [];
324417
resetSkinnedMeshesToBindPose();
325418
animationStatus.value = '待機モーション停止中';
326419
};
327420
328-
const loadIdleBvh = async () => {
421+
const loadSelectedBvh = async () => {
422+
const motionPath = currentMotionPath.value;
329423
try {
330-
const loader = new BVHLoader();
331-
const result = await loader.loadAsync('/animations/avatar_stand.bvh');
332-
bvhIdleClip = result.clip;
333-
animationStatus.value = `待機モーション読込済み (frames: ${Math.round(result.clip.duration)})`;
424+
const cached = bvhClipCache.get(motionPath);
425+
if (cached) {
426+
bvhMotionClip = cached;
427+
animationStatus.value = `モーション読込済み: ${motionPath.split('/').pop()} (frames: ${Math.max(...cached.tracks.map(t => t.times.length), 0)})`;
428+
} else {
429+
const loader = new BVHLoader();
430+
const result = await loader.loadAsync(motionPath);
431+
bvhMotionClip = result.clip;
432+
bvhClipCache.set(motionPath, result.clip);
433+
animationStatus.value = `モーション読込済み: ${motionPath.split('/').pop()} (frames: ${Math.max(...result.clip.tracks.map(t => t.times.length), 0)})`;
434+
}
334435
335436
if (modelRoot && animationEnabled.value) {
336437
applyIdleAnimation();
337438
}
338439
} catch (error) {
339-
bvhIdleClip = null;
340-
animationStatus.value = `待機モーション読込失敗: ${String(error)}`;
440+
bvhMotionClip = null;
441+
animationStatus.value = `モーション読込失敗 (${motionPath}): ${String(error)}`;
341442
}
342443
};
343444
@@ -376,6 +477,9 @@ const loadPreviewModel = async (path: string, options: ConvertOptions) => {
376477
errorMessage.value = '';
377478
378479
try {
480+
avatarGender.value = await detectGenderFromVrm(path);
481+
applyMotionSelection();
482+
379483
const previewPath = await invoke<string>('build_preview_glb_command', {
380484
request: {
381485
input_path: path,
@@ -417,7 +521,12 @@ const loadPreviewModel = async (path: string, options: ConvertOptions) => {
417521
fitCameraToModel(modelRoot);
418522
419523
if (animationEnabled.value) {
420-
applyIdleAnimation();
524+
bvhMotionClip = bvhClipCache.get(currentMotionPath.value) ?? null;
525+
if (bvhMotionClip) {
526+
applyIdleAnimation();
527+
} else {
528+
await loadSelectedBvh();
529+
}
421530
}
422531
} catch (error) {
423532
errorMessage.value = `Preview failed: ${String(error)}`;
@@ -451,6 +560,8 @@ onMounted(() => {
451560
return;
452561
}
453562
563+
applyMotionSelection();
564+
454565
scene = new THREE.Scene();
455566
scene.background = new THREE.Color(0x1f1f1f);
456567
@@ -474,7 +585,7 @@ onMounted(() => {
474585
scene.add(ambient);
475586
476587
const directional = new THREE.DirectionalLight(0xffffff, 0.9);
477-
directional.position.set(1.5, 2.5, 2.0);
588+
directional.position.set(1.5, 2.5, 2);
478589
scene.add(directional);
479590
480591
const grid = new THREE.GridHelper(10, 20, 0x555555, 0x333333);
@@ -527,9 +638,11 @@ watch(
527638
() => animationEnabled.value,
528639
enabled => {
529640
if (enabled) {
530-
if (!bvhIdleClip) {
531-
void loadIdleBvh();
641+
applyMotionSelection();
642+
if (!bvhMotionClip || !bvhClipCache.has(currentMotionPath.value)) {
643+
void loadSelectedBvh();
532644
} else {
645+
bvhMotionClip = bvhClipCache.get(currentMotionPath.value) ?? null;
533646
applyIdleAnimation();
534647
}
535648
return;
@@ -538,6 +651,21 @@ watch(
538651
}
539652
);
540653
654+
watch(
655+
() => selectedMotionMode.value,
656+
() => {
657+
applyMotionSelection();
658+
bvhMotionClip = bvhClipCache.get(currentMotionPath.value) ?? null;
659+
if (animationEnabled.value) {
660+
if (bvhMotionClip) {
661+
applyIdleAnimation();
662+
} else {
663+
void loadSelectedBvh();
664+
}
665+
}
666+
}
667+
);
668+
541669
onBeforeUnmount(() => {
542670
if (reloadTimer) {
543671
clearTimeout(reloadTimer);
@@ -551,7 +679,7 @@ onBeforeUnmount(() => {
551679
renderer?.dispose();
552680
553681
if (renderer?.domElement.parentElement) {
554-
renderer.domElement.parentElement.removeChild(renderer.domElement);
682+
renderer.domElement.remove();
555683
}
556684
557685
scene = null;
@@ -572,14 +700,27 @@ onBeforeUnmount(() => {
572700
<v-card-text>
573701
<div ref="canvasHost" class="preview-host" />
574702
<div class="d-flex flex-wrap ga-3 align-center mt-3">
703+
<v-select
704+
v-model="selectedMotionMode"
705+
:items="MOTION_MODE_ITEMS"
706+
item-title="title"
707+
item-value="value"
708+
density="compact"
709+
hide-details
710+
label="モーション"
711+
style="max-width: 180px"
712+
/>
575713
<v-switch
576714
v-model="animationEnabled"
577715
color="primary"
578716
density="compact"
579717
hide-details
580-
label="待機モーション (avatar_stand.bvh)"
718+
:label="`モーション再生 (${currentMotionPath.split('/').pop()})`"
581719
/>
582720
</div>
721+
<div class="text-caption text-medium-emphasis mt-1">
722+
性別判定: {{ avatarGender }} / 自動選択: {{ currentMotionPath.split('/').pop() }}
723+
</div>
583724
<v-alert v-if="loading" type="info" class="mt-2" variant="tonal">読み込み中...</v-alert>
584725
<v-alert v-else-if="errorMessage" type="error" class="mt-2" variant="tonal">
585726
{{ errorMessage }}

0 commit comments

Comments
 (0)