diff --git a/LICENSE b/LICENSE
index 511f0931..f19e2338 100644
--- a/LICENSE
+++ b/LICENSE
@@ -19,8 +19,8 @@ copy, modify, and distribute copies of the Software for **non-commercial purpose
implied, including but not limited to the warranties of merchantability,
fitness for a particular purpose, and noninfringement.
-*Note: This license applies only to non-commercial use. For commercial use,
-please refer to the Commercial License below.*
+_Note: This license applies only to non-commercial use. For commercial use,
+please refer to the Commercial License below._
===============================================================================
Commercial License
diff --git a/locales/en/translation.json b/locales/en/translation.json
index de38d892..73d56dc5 100644
--- a/locales/en/translation.json
+++ b/locales/en/translation.json
@@ -344,6 +344,9 @@
"CustomAPIDescription": "Note: Messages are automatically included in the request body. In streaming mode, the server must return text/event-stream.",
"EditSlideScripts": "Edit Dialogue",
"PleaseSelectSlide": "Please select a slide",
+ "QueueCheckDelay": "Queue Check Delay",
+ "QueueCheckDelayInfo": "Time to wait before resetting to neutral expression after speech queue becomes empty (in seconds).",
+ "QueueCheckDelayLabel": "Queue Check Delay: {{delay}}s",
"XAIAPIKeyLabel": "xAI API Key",
"DynamicRetrievalDescription": "Sets the threshold for when the model performs a search. If 0, the search is always performed; if 1, the search is never performed.",
"DynamicRetrieval": "Dynamic Search",
diff --git a/locales/ja/translation.json b/locales/ja/translation.json
index 107c2bfe..81fcc9bf 100644
--- a/locales/ja/translation.json
+++ b/locales/ja/translation.json
@@ -367,6 +367,9 @@
"XAIAPIKeyLabel": "xAI APIキー",
"OpenRouterAPIKeyLabel": "OpenRouter APIキー",
"OpenRouterModelNameInstruction": "OpenRouterからモデル識別子を入力してください(例: \"openai/gpt-4o\", \"mistralai/mistral-large-latest\")。モデル識別子はOpenRouterモデルページで確認できます。",
+ "QueueCheckDelay": "キューチェック遅延",
+ "QueueCheckDelayInfo": "音声キューが空になった後、ニュートラル表情にリセットするまでの待機時間(秒)。",
+ "QueueCheckDelayLabel": "キューチェック遅延: {{delay}}秒",
"ImageDisplayPosition": "画像表示位置",
"ImageDisplayPositionDescription": "アップロードした画像を表示する位置を選択してください",
"InputArea": "入力エリア",
diff --git a/public/idle_loop.vrma b/public/idle_loop.vrma
index 26b28f4e..507cf56f 100644
Binary files a/public/idle_loop.vrma and b/public/idle_loop.vrma differ
diff --git a/public/idle_loop_.vrma b/public/idle_loop_.vrma
new file mode 100644
index 00000000..26b28f4e
Binary files /dev/null and b/public/idle_loop_.vrma differ
diff --git a/public/vrma/angry.vrma b/public/vrma/angry.vrma
new file mode 100644
index 00000000..ee53a821
Binary files /dev/null and b/public/vrma/angry.vrma differ
diff --git a/public/vrma/happy.vrma b/public/vrma/happy.vrma
new file mode 100644
index 00000000..18351775
Binary files /dev/null and b/public/vrma/happy.vrma differ
diff --git a/public/vrma/sad.vrma b/public/vrma/sad.vrma
new file mode 100644
index 00000000..64662e2e
Binary files /dev/null and b/public/vrma/sad.vrma differ
diff --git a/src/components/settings/character.tsx b/src/components/settings/character.tsx
index b7b93877..1b1c466c 100644
--- a/src/components/settings/character.tsx
+++ b/src/components/settings/character.tsx
@@ -787,6 +787,31 @@ const Character = () => {
)
})}
+
+
+
{t('QueueCheckDelay')}
+
+ {t('QueueCheckDelayInfo')}
+
+
+ {t('QueueCheckDelayLabel', {
+ delay: settingsStore((s) => s.queueCheckDelay),
+ })}
+
+
s.queueCheckDelay)}
+ onChange={(e) =>
+ settingsStore.setState({
+ queueCheckDelay: parseFloat(e.target.value),
+ })
+ }
+ className="mt-2 mb-4 input-range"
+ />
+
>
)
diff --git a/src/components/vrmViewer.tsx b/src/components/vrmViewer.tsx
index 876c0940..29ea9714 100644
--- a/src/components/vrmViewer.tsx
+++ b/src/components/vrmViewer.tsx
@@ -40,6 +40,10 @@ export default function VrmViewer() {
const image = reader.result as string
image !== '' && homeStore.setState({ modalImage: image })
}
+ } else if (file_type === 'vrma') {
+ const blob = new Blob([file], { type: 'application/octet-stream' })
+ const url = window.URL.createObjectURL(blob)
+ viewer.loadVrma(url)
}
})
}
diff --git a/src/features/messages/speakCharacter.ts b/src/features/messages/speakCharacter.ts
index 8905388a..7c4c42e4 100644
--- a/src/features/messages/speakCharacter.ts
+++ b/src/features/messages/speakCharacter.ts
@@ -206,6 +206,7 @@ const createSpeakCharacter = () => {
) => {
let called = false
const ss = settingsStore.getState()
+ const hs = homeStore.getState()
onStart?.()
const initialToken = SpeakQueue.currentStopToken
@@ -327,6 +328,15 @@ const createSpeakCharacter = () => {
isNeedDecode: result.isNeedDecode,
onComplete: guardedOnComplete, // Pass the guarded function
})
+
+ // VRMの場合、ここで感情アニメーションを再生
+ if (ss.modelType === 'vrm') {
+ if (hs.viewer && talk.emotion && talk.emotion !== 'neutral') {
+ // neutral は常時アイドルアニメーションが流れているため、個別に再生しない
+ hs.viewer.playEmotionAnimation(talk.emotion, 0.3)
+ console.log(`VRM emotion animation started: ${talk.emotion}`)
+ }
+ }
})
.catch((error) => {
console.error('Error in processAndSynthesizePromise chain:', error)
diff --git a/src/features/messages/speakQueue.ts b/src/features/messages/speakQueue.ts
index 52e21d9b..4c9b620c 100644
--- a/src/features/messages/speakQueue.ts
+++ b/src/features/messages/speakQueue.ts
@@ -12,7 +12,6 @@ type SpeakTask = {
}
export class SpeakQueue {
- private static readonly QUEUE_CHECK_DELAY = 1500
private queue: SpeakTask[] = []
private isProcessing = false
private currentSessionId: string | null = null
@@ -148,8 +147,9 @@ export class SpeakQueue {
private async scheduleNeutralExpression() {
const initialLength = this.queue.length
+ const ss = settingsStore.getState()
await new Promise((resolve) =>
- setTimeout(resolve, SpeakQueue.QUEUE_CHECK_DELAY)
+ setTimeout(resolve, ss.queueCheckDelay * 1000)
)
if (this.shouldResetToNeutral(initialLength)) {
@@ -159,6 +159,7 @@ export class SpeakQueue {
await Live2DHandler.resetToIdle()
} else {
await hs.viewer.model?.playEmotion('neutral')
+ hs.viewer.switchToIdleAnimation()
}
}
}
diff --git a/src/features/stores/settings.ts b/src/features/stores/settings.ts
index f7b8c827..4ecae84b 100644
--- a/src/features/stores/settings.ts
+++ b/src/features/stores/settings.ts
@@ -206,6 +206,7 @@ interface General {
whisperTranscriptionModel: WhisperTranscriptionModel
initialSpeechTimeout: number
chatLogWidth: number
+ queueCheckDelay: number
imageDisplayPosition: 'input' | 'side' | 'icon'
multiModalMode: 'ai-decide' | 'always' | 'never'
multiModalAiDecisionPrompt: string
@@ -538,6 +539,8 @@ const getInitialValuesFromEnv = (): SettingsState => ({
angryMotionGroup: process.env.NEXT_PUBLIC_ANGRY_MOTION_GROUP || '',
relaxedMotionGroup: process.env.NEXT_PUBLIC_RELAXED_MOTION_GROUP || '',
surprisedMotionGroup: process.env.NEXT_PUBLIC_SURPRISED_MOTION_GROUP || '',
+ queueCheckDelay:
+ parseFloat(process.env.NEXT_PUBLIC_QUEUE_CHECK_DELAY || '1.5') || 1.5,
})
const settingsStore = create()(
@@ -710,6 +713,7 @@ const settingsStore = create()(
enableMultiModal: state.enableMultiModal,
colorTheme: state.colorTheme,
customModel: state.customModel,
+ queueCheckDelay: state.queueCheckDelay,
}),
})
)
diff --git a/src/features/vrmViewer/model.ts b/src/features/vrmViewer/model.ts
index 5886f8c0..2bae1c15 100644
--- a/src/features/vrmViewer/model.ts
+++ b/src/features/vrmViewer/model.ts
@@ -11,6 +11,8 @@ import { VRMLookAtSmootherLoaderPlugin } from '@/lib/VRMLookAtSmootherLoaderPlug
import { LipSync } from '../lipSync/lipSync'
import { EmoteController } from '../emoteController/emoteController'
import { Talk } from '../messages/messages'
+import { loadVRMAnimationClip } from '@/lib/VRMAnimation/loadVRMAnimation'
+import { buildUrl } from '@/utils/buildUrl'
/**
* 3Dキャラクターを管理するクラス
@@ -19,6 +21,21 @@ export class Model {
public vrm?: VRM | null
public mixer?: THREE.AnimationMixer
public emoteController?: EmoteController
+ private _animationActions: Map = new Map()
+ private _currentAction: THREE.AnimationAction | null = null
+ private _idleAnimationName: string = 'idle_default' // Default idle animation name
+
+ // 仮の感情アニメーションマッピング (パスはsettingsStoreなどから取得することを想定)
+ private _emotionAnimationPaths: Map = new Map([
+ ['neutral', '/neutral.vrma'], // デフォルトアイドルをneutralとする
+ ['happy', '/vrma/happy.vrma'], // TODO: 実際のパスに置き換える
+ ['sad', '/vrma/sad.vrma'], // TODO: 実際のパスに置き換える
+ ['angry', '/vrma/angry.vrma'], // TODO: 実際のパスに置き換える
+ // 他の感情も追加可能
+ ])
+ private _isEmotionAnimating: boolean = false
+ private _watchingEmotionAction: THREE.AnimationAction | null = null
+ private _emotionFadeOutThreshold: number = 0.5 // 終了の何秒前にアイドルへ移行するか
private _lookAtTargetParent: THREE.Object3D
private _lipSync?: LipSync
@@ -56,19 +73,241 @@ export class Model {
}
/**
- * VRMアニメーションを読み込む
- *
- * https://github.com/vrm-c/vrm-specification/blob/master/specification/VRMC_vrm_animation-1.0/README.ja.md
+ * VRMアニメーションを読み込む(従来の方法)
+ */
+ public async loadAnimation(
+ vrmAnimation: VRMAnimation,
+ animationName?: string
+ ): Promise {
+ if (this.vrm == null || this.mixer == null) {
+ console.error('VRM or Mixer not initialized in loadAnimation')
+ throw new Error('You have to load VRM first')
+ }
+
+ const clip = vrmAnimation.createAnimationClip(this.vrm)
+ const action = this.mixer.clipAction(clip)
+
+ const name =
+ animationName || clip.name || `animation_${this._animationActions.size}`
+ clip.name = name
+ if (this._animationActions.has(name)) {
+ console.warn(`Animation "${name}" already exists. Overwriting.`)
+ this._animationActions.get(name)?.stop()
+ }
+ this._animationActions.set(name, action)
+
+ console.log(`Animation "${name}" loaded.`)
+ return action
+ }
+
+ /**
+ * VRMアニメーションを読み込む(ベストプラクティス準拠)
*/
- public async loadAnimation(vrmAnimation: VRMAnimation): Promise {
- const { vrm, mixer } = this
- if (vrm == null || mixer == null) {
+ public async loadAnimationFromUrl(
+ url: string,
+ animationName?: string
+ ): Promise {
+ if (this.vrm == null || this.mixer == null) {
+ console.error('VRM or Mixer not initialized in loadAnimationFromUrl')
throw new Error('You have to load VRM first')
}
- const clip = vrmAnimation.createAnimationClip(vrm)
- const action = mixer.clipAction(clip)
- action.play()
+ try {
+ const clip = await loadVRMAnimationClip(url, this.vrm)
+ if (!clip) {
+ console.warn(`Failed to load animation clip from ${url}`)
+ return undefined
+ }
+
+ const action = this.mixer.clipAction(clip)
+ const name =
+ animationName || clip.name || `animation_${this._animationActions.size}`
+ clip.name = name
+
+ if (this._animationActions.has(name)) {
+ console.warn(`Animation "${name}" already exists. Overwriting.`)
+ this._animationActions.get(name)?.stop()
+ }
+ this._animationActions.set(name, action)
+
+ console.log(`Animation "${name}" loaded from URL with best practices.`)
+ return action
+ } catch (error) {
+ console.error(`Error loading animation from ${url}:`, error)
+ return undefined
+ }
+ }
+
+ /**
+ * 不要なTranslationトラックを除去(Hips以外)
+ */
+ private removeUnnecessaryTranslationTracks(clip: THREE.AnimationClip) {
+ clip.tracks = clip.tracks.filter((track) => {
+ // Hipsボーンの位置変化は保持、その他のTranslationは除去
+ if (
+ track.name.includes('.position') &&
+ !track.name.toLowerCase().includes('hips')
+ ) {
+ console.log(`Removing unnecessary translation track: ${track.name}`)
+ return false
+ }
+ return true
+ })
+ }
+
+ /**
+ * 足を地面に固定する処理
+ */
+ private constrainFeetToGround() {
+ if (!this.vrm?.humanoid) return
+
+ const leftFoot = this.vrm.humanoid.getNormalizedBoneNode('leftFoot')
+ const rightFoot = this.vrm.humanoid.getNormalizedBoneNode('rightFoot')
+
+ // 地面のY座標(通常は0)
+ const groundY = 0
+
+ if (leftFoot) {
+ const worldPosition = new THREE.Vector3()
+ leftFoot.getWorldPosition(worldPosition)
+
+ // 足が地面より下にある場合、地面の高さに調整
+ if (worldPosition.y < groundY) {
+ const localPosition = leftFoot.position.clone()
+ localPosition.y += groundY - worldPosition.y
+ leftFoot.position.copy(localPosition)
+ }
+ }
+
+ if (rightFoot) {
+ const worldPosition = new THREE.Vector3()
+ rightFoot.getWorldPosition(worldPosition)
+
+ // 足が地面より下にある場合、地面の高さに調整
+ if (worldPosition.y < groundY) {
+ const localPosition = rightFoot.position.clone()
+ localPosition.y += groundY - worldPosition.y
+ rightFoot.position.copy(localPosition)
+ }
+ }
+ }
+
+ public async loadFbxAnimation(
+ clip: THREE.AnimationClip,
+ animationName?: string // アニメーションを識別するための名前
+ ): Promise {
+ if (this.vrm == null || this.mixer == null) {
+ throw new Error('You have to load VRM first')
+ }
+
+ // this.mixer = new THREE.AnimationMixer(this.vrm.scene) // reset animation mixer, otherwise funny merge
+
+ const action = this.mixer.clipAction(clip)
+ const name =
+ animationName || clip.name || `animation_${this._animationActions.size}`
+ clip.name = name // Ensure the AnimationClip itself has the correct name
+ this._animationActions.set(name, action)
+
+ return action
+ }
+
+ /**
+ * 指定された名前のアニメーションを再生する(クロスフェードなし)
+ */
+ public playAnimation(
+ name: string,
+ loop: THREE.AnimationActionLoopStyles = THREE.LoopRepeat
+ ) {
+ const action = this._animationActions.get(name)
+ if (!action) {
+ console.warn(`Animation "${name}" not found.`)
+ return
+ }
+
+ if (this._currentAction && this._currentAction !== action) {
+ this._currentAction.stop()
+ }
+
+ action.reset().setLoop(loop, Infinity).play()
+ this._currentAction = action
+ }
+
+ /**
+ * 指定された名前のアニメーションにクロスフェードで遷移する
+ * @param name 再生するアニメーション名
+ * @param duration フェード時間 (秒)
+ */
+ public crossFadeToAnimation(
+ name: string,
+ duration: number,
+ loop: THREE.AnimationActionLoopStyles = THREE.LoopRepeat
+ ) {
+ const nextAction = this._animationActions.get(name)
+ if (!nextAction) {
+ console.warn(`Animation "${name}" not found for crossfade.`)
+ return
+ }
+
+ // 現在アクションが同じで既に再生中の場合は何もしない
+ if (this._currentAction === nextAction && nextAction.isRunning()) {
+ console.log(`Animation "${name}" is already current and running.`)
+ return
+ }
+
+ // crossfade 可能かどうか判定
+ const canCrossFade =
+ this._currentAction && this._currentAction !== nextAction
+
+ console.log(
+ `[crossFadeToAnimation] canCrossFade: ${canCrossFade}, currentAction: ${this._currentAction?.getClip().name}, nextAction: ${nextAction.getClip().name}`
+ )
+
+ // 次のアクションを準備して再生
+ nextAction.reset().setLoop(loop, Infinity).play()
+
+ if (canCrossFade) {
+ // 通常のクロスフェード
+ this._currentAction!.crossFadeTo(nextAction, duration, true)
+ } else {
+ // クロスフェードできない場合はすぐに次アクションへ切り替え
+ // 1) 以前のアクションがまだ残っていれば停止してウェイトを 0 にする
+ if (this._currentAction && this._currentAction !== nextAction) {
+ this._currentAction.stop()
+ }
+
+ // 2) 次アクションを有効化しウェイトを 1 に設定して即時反映 (T ポーズ防止)
+ nextAction.enabled = true
+ nextAction.setEffectiveWeight(0.05)
+ }
+
+ this._currentAction = nextAction
+ this._isEmotionAnimating =
+ name !== this._idleAnimationName && loop === THREE.LoopOnce
+ }
+
+ /**
+ * 現在のアニメーションを停止する
+ */
+ public stopCurrentAnimation(duration: number = 0.25) {
+ if (this._currentAction) {
+ // アイドルアニメーションなどがあれば、そちらにフェードアウトすることも検討
+ this._currentAction.fadeOut(duration)
+ // this._currentAction = null // アイドルへの遷移後にnull化など
+ }
+ }
+
+ /**
+ * すべてのアニメーションアクションを停止・リセットする
+ */
+ public resetAnimations() {
+ this.mixer?.stopAllAction()
+ // アクション自体は保持しておくので、クリアはしない
+ // this._animationActions.forEach(action => {
+ // // 必要に応じて action.reset() なども
+ // })
+ this._currentAction = null
+ this._isEmotionAnimating = false
+ this._watchingEmotionAction = null
}
/**
@@ -105,6 +344,66 @@ export class Model {
this.emoteController?.playEmotion(preset)
}
+ /**
+ * アイドルアニメーションの名前を設定する
+ */
+ public setIdleAnimationName(name: string) {
+ this._idleAnimationName = name
+ }
+
+ /**
+ * 定義された感情アニメーションをすべてロードする
+ */
+ public async loadAllEmotionAnimations(): Promise {
+ if (!this.vrm || !this.mixer) {
+ console.error('VRM or Mixer not ready for loading emotion animations.')
+ return
+ }
+ console.log('Loading all emotion animations...')
+ for (const [emotion, path] of this._emotionAnimationPaths) {
+ try {
+ // buildUrlを使用してパスを構築
+ const fullPath = buildUrl(path)
+ // ベストプラクティス準拠の新しいメソッドを使用
+ await this.loadAnimationFromUrl(fullPath, emotion)
+ } catch (error) {
+ console.error(
+ `Error loading emotion animation for "${emotion}" from ${path}:`,
+ error
+ )
+ }
+ }
+ console.log('Finished loading emotion animations.')
+ }
+
+ /**
+ * 指定された感情のアニメーションを再生する。
+ * 再生終了後 (ループしない場合) はアイドルアニメーションに戻る。
+ */
+ public playEmotionAnimation(
+ emotionName: string,
+ crossFadeDuration: number = 0.3
+ ) {
+ if (!this._animationActions.has(this._idleAnimationName)) {
+ console.warn(
+ 'Idle animation is not loaded. Cannot properly manage emotion animations.'
+ )
+ // アイドルがない場合は、単純に指定された感情アニメーションを再生するだけでも良いかもしれない
+ }
+
+ const emotionAction = this._animationActions.get(emotionName)
+ if (!emotionAction) {
+ console.warn(`Emotion animation "${emotionName}" not found.`)
+ return
+ }
+
+ console.log(
+ `[playEmotionAnimation] Starting emotion: "${emotionName}". Current action: "${this._currentAction?.getClip().name}", Crossfade duration: ${crossFadeDuration}`
+ )
+ this.crossFadeToAnimation(emotionName, crossFadeDuration, THREE.LoopRepeat)
+ this._isEmotionAnimating = true // 感情アニメーション中フラグを立てる
+ }
+
public update(delta: number): void {
if (this._lipSync) {
const { volume } = this._lipSync.update()
@@ -113,6 +412,50 @@ export class Model {
this.emoteController?.update(delta)
this.mixer?.update(delta)
+
+ // アニメーション更新後に足を地面に固定
+ if (this._currentAction && this._currentAction.isRunning()) {
+ this.constrainFeetToGround()
+ }
+
this.vrm?.update(delta)
}
+
+ /**
+ * アイドルアニメーションに戻る
+ * @param crossFadeDuration クロスフェード時間 (秒)
+ */
+ public returnToIdleAnimation(crossFadeDuration: number = 0.3) {
+ if (!this.vrm || !this.mixer) {
+ console.warn('VRM or Mixer not ready to return to idle animation.')
+ return
+ }
+
+ if (!this._animationActions.has(this._idleAnimationName)) {
+ console.warn(
+ `Idle animation "${this._idleAnimationName}" not loaded. Cannot return to idle.`
+ )
+ return
+ }
+
+ // 現在再生中のアニメーションがアイドルアニメーションでなければ遷移する
+ if (
+ !this._currentAction ||
+ this._currentAction.getClip().name !== this._idleAnimationName
+ ) {
+ console.log(
+ `[returnToIdleAnimation] Transitioning to idle: "${this._idleAnimationName}", Crossfade duration: ${crossFadeDuration}`
+ )
+ this.crossFadeToAnimation(
+ this._idleAnimationName,
+ crossFadeDuration,
+ THREE.LoopRepeat
+ )
+ this._isEmotionAnimating = false
+ } else {
+ console.log(
+ `[returnToIdleAnimation] Already in idle animation: "${this._idleAnimationName}". No transition needed.`
+ )
+ }
+ }
}
diff --git a/src/features/vrmViewer/viewer.ts b/src/features/vrmViewer/viewer.ts
index 582e9d1f..21e9770d 100644
--- a/src/features/vrmViewer/viewer.ts
+++ b/src/features/vrmViewer/viewer.ts
@@ -1,3 +1,4 @@
+const defaultAnimationUrl = buildUrl('/idle_loop.vrma')
import * as THREE from 'three'
import { Model } from './model'
import { loadVRMAnimation } from '@/lib/VRMAnimation/loadVRMAnimation'
@@ -6,13 +7,15 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import settingsStore from '@/features/stores/settings'
/**
- * three.jsを使った3Dビューワー
+ * three.js を使用した 3D ビューア
*
- * setup()でcanvasを渡してから使う
+ * setup() で canvas を渡してから使用
*/
export class Viewer {
public isReady: boolean
public model?: Model
+ private readonly idleAnimationName = 'idle_default' // アイドルアニメーションのデフォルト名
+ private readonly defaultIdleAnimationPath = '/idle_loop.vrma' // デフォルトアイドルアニメーションのパス
private _renderer?: THREE.WebGLRenderer
private _clock: THREE.Clock
@@ -22,7 +25,14 @@ export class Viewer {
private _directionalLight?: THREE.DirectionalLight
private _ambientLight?: THREE.AmbientLight
+ private _currentAnimationUrl: string
+ private _currentAnimationType: string
+
constructor() {
+ // current animation
+ this._currentAnimationUrl = defaultAnimationUrl
+ this._currentAnimationType = 'vrma'
+
this.isReady = false
// scene
@@ -65,11 +75,37 @@ export class Viewer {
})
this._scene.add(this.model.vrm.scene)
+ this.model.setIdleAnimationName(this.idleAnimationName) // アイドルアニメーション名を設定
+
+ // Load and play default idle animation
+ // _currentAnimationUrl と _currentAnimationType はデフォルトアイドルアニメーションのパスとタイプとして解釈
+ // 将来的には settingsStore などから取得するのが良いと想定
+ this._currentAnimationUrl = buildUrl(this.defaultIdleAnimationPath)
+ this._currentAnimationType = 'vrma'
+
+ if (this._currentAnimationUrl && this._currentAnimationType === 'vrma') {
+ try {
+ const vrma = await loadVRMAnimation(this._currentAnimationUrl) // buildUrl で処理されたパスを使用
+ if (vrma && this.model) {
+ await this.model.loadAnimation(vrma, this.idleAnimationName)
+ this.model.playAnimation(this.idleAnimationName, THREE.LoopRepeat)
+ console.log(
+ `Default animation '${this.idleAnimationName}' loaded and playing.`
+ )
+
+ // アイドルアニメーションをロードした後、他の感情アニメーションもロード
+ await this.model.loadAllEmotionAnimations()
+ } else {
+ console.warn(
+ 'Failed to load default VRMA animation or model not ready.'
+ )
+ }
+ } catch (error) {
+ console.error('Error loading default VRMA animation:', error)
+ }
+ }
- const vrma = await loadVRMAnimation(buildUrl('/idle_loop.vrma'))
- if (vrma) this.model.loadAnimation(vrma)
-
- // HACK: アニメーションの原点がずれているので再生後にカメラ位置を調整する
+ // HACK: アニメーションの原点に誤差があるため、後処理でカメラ位置を調整します。
requestAnimationFrame(() => {
this.resetCamera()
})
@@ -79,12 +115,63 @@ export class Viewer {
public unloadVRM(): void {
if (this.model?.vrm) {
this._scene.remove(this.model.vrm.scene)
+ this.model?.resetAnimations() // Unload時にアニメーションもリセット
this.model?.unLoadVrm()
+ this.model = undefined
+ }
+ }
+
+ public async loadVrma(
+ url: string,
+ animationName?: string,
+ loop: boolean = true
+ ) {
+ if (!this.model?.vrm) {
+ console.warn('VRM model not loaded. Cannot load VRMA animation.')
+ return
+ }
+ const nameToLoad = animationName || `vrma_${Date.now()}` // 適切な名前を生成
+ // this._currentAnimationUrl = url // 最後にロードしたURLを維持する必要性は減りました。
+ // this._currentAnimationType = 'vrma'
+
+ try {
+ const vrma = await loadVRMAnimation(url)
+ if (vrma && this.model) {
+ await this.model.loadAnimation(vrma, nameToLoad)
+ this.model.crossFadeToAnimation(
+ nameToLoad,
+ 0.5,
+ loop ? THREE.LoopRepeat : THREE.LoopOnce
+ )
+ console.log(
+ `VRMA animation '${nameToLoad}' loaded and playing with crossfade (loop: ${loop}).`
+ )
+ } else {
+ console.warn(`Failed to load VRMA from ${url} or model not ready.`)
+ }
+ } catch (error) {
+ console.error(`Error loading VRMA animation from ${url}:`, error)
+ }
+ }
+
+ /**
+ * 指定された感情のアニメーションを再生します。
+ * @param emotionName 再生する感情の名前 (例: "happy", "sad")
+ * @param crossFadeDuration フェード時間 (秒)
+ */
+ public playEmotionAnimation(
+ emotionName: string,
+ crossFadeDuration: number = 0.3
+ ) {
+ if (this.model) {
+ this.model.playEmotionAnimation(emotionName, crossFadeDuration)
+ } else {
+ console.warn('Model not loaded. Cannot play emotion animation.')
}
}
/**
- * Reactで管理しているCanvasを後から設定する
+ * React で管理する Canvas を後で設定する
*/
public setup(canvas: HTMLCanvasElement) {
const parentElement = canvas.parentElement
@@ -130,7 +217,7 @@ export class Viewer {
}
/**
- * canvasの親要素を参照してサイズを変更する
+ * canvas の親要素を参照してサイズを変更します。
*/
public resize() {
if (!this._renderer) return
@@ -150,7 +237,7 @@ export class Viewer {
}
/**
- * VRMのheadノードを参照してカメラ位置を調整する
+ * VRM の head ノードを参照してカメラ位置を調整します。
*/
public resetCamera() {
const { fixedCharacterPosition } = settingsStore.getState()
@@ -187,6 +274,18 @@ export class Viewer {
}
}
+ /**
+ * アイドルアニメーションに切り替える
+ * @param crossFadeDuration クロスフェード時間 (秒)
+ */
+ public switchToIdleAnimation(crossFadeDuration: number = 0.3) {
+ if (this.model) {
+ this.model.returnToIdleAnimation(crossFadeDuration)
+ } else {
+ console.warn('Model not loaded. Cannot switch to idle animation.')
+ }
+ }
+
/**
* 現在のカメラ位置を設定に保存する
*/
diff --git a/src/lib/VRMAnimation/loadVRMAnimation.ts b/src/lib/VRMAnimation/loadVRMAnimation.ts
index aaacc4f5..56515c00 100644
--- a/src/lib/VRMAnimation/loadVRMAnimation.ts
+++ b/src/lib/VRMAnimation/loadVRMAnimation.ts
@@ -1,8 +1,12 @@
+import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { VRMLoaderPlugin } from '@pixiv/three-vrm'
import { VRMAnimation } from './VRMAnimation'
import { VRMAnimationLoaderPlugin } from './VRMAnimationLoaderPlugin'
+import type { VRM } from '@pixiv/three-vrm'
const loader = new GLTFLoader()
+loader.register((parser) => new VRMLoaderPlugin(parser))
loader.register((parser) => new VRMAnimationLoaderPlugin(parser))
export async function loadVRMAnimation(
@@ -15,3 +19,25 @@ export async function loadVRMAnimation(
return vrmAnimation ?? null
}
+
+/**
+ * ベストプラクティスに従ったVRMAアニメーションの読み込みとAnimationClip作成
+ */
+export async function loadVRMAnimationClip(
+ url: string,
+ vrm: VRM
+): Promise {
+ try {
+ const gltf = await loader.loadAsync(url)
+ const vrmAnimations = gltf.userData.vrmAnimations
+ if (vrmAnimations && vrmAnimations.length > 0) {
+ // VRMAnimationからAnimationClipを作成
+ const vrmAnimation: VRMAnimation = vrmAnimations[0]
+ return vrmAnimation.createAnimationClip(vrm)
+ }
+ return null
+ } catch (error) {
+ console.error('Error loading VRM animation clip:', error)
+ return null
+ }
+}