@@ -17,7 +17,14 @@ const canvasHost = ref<HTMLDivElement | null>(null);
1717const errorMessage = ref (' ' );
1818const loading = ref (false );
1919const 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
2229let scene: THREE .Scene | null = null ;
2330let camera: THREE .PerspectiveCamera | null = null ;
@@ -28,9 +35,14 @@ let resizeObserver: ResizeObserver | null = null;
2835let animationFrameId = 0 ;
2936let reloadTimer: ReturnType <typeof setTimeout > | null = null ;
3037let clock: THREE .Clock | null = null ;
31- let bvhIdleClip : THREE .AnimationClip | null = null ;
38+ let bvhMotionClip : THREE .AnimationClip | null = null ;
3239let 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
3547const 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+
68167const 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
204301const 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
252349const 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+
541669onBeforeUnmount (() => {
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