mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
feat(stage-ui-mmd): add MMD support (#1997)
--------- Co-authored-by: nyueki <nyuek.i@proton.me> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Neko Ayaka <neko@ayaka.moe> Co-authored-by-agent: Unknown
This commit is contained in:
co-authored by
nyueki
autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Neko Ayaka
parent
78d6588cb0
commit
eabec9a64f
@@ -3,7 +3,10 @@ import { defineConfig } from 'vitest/config'
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
fileParallelism: false,
|
||||
globals: true,
|
||||
hookTimeout: 60_000,
|
||||
maxWorkers: 1,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: [
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"@proj-airi/stage-pages": "workspace:^",
|
||||
"@proj-airi/stage-ui": "workspace:^",
|
||||
"@proj-airi/stage-ui-live2d": "workspace:^",
|
||||
"@proj-airi/stage-ui-mmd": "workspace:^",
|
||||
"@proj-airi/stage-ui-spine": "workspace:^",
|
||||
"@proj-airi/stage-ui-three": "workspace:^",
|
||||
"@proj-airi/ui": "workspace:^",
|
||||
|
||||
@@ -190,6 +190,20 @@ const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() =
|
||||
})
|
||||
}
|
||||
|
||||
if (stageModelRenderer.value === 'mmd') {
|
||||
const phase = resolveComponentStateToRuntimePhase(componentStateStage.value, { hasModel })
|
||||
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
||||
renderer: 'mmd',
|
||||
phase,
|
||||
controlsLocked: hasModel ? phase !== 'mounted' : false,
|
||||
previewAvailable: hasModel,
|
||||
canCapturePreview: false,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
if (stageModelRenderer.value === 'godot') {
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
||||
|
||||
@@ -13,5 +13,7 @@ export default defineConfig({
|
||||
env: loadEnv('test', cwd(), ''),
|
||||
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', '**/.git/**'],
|
||||
fileParallelism: false,
|
||||
maxWorkers: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -119,7 +119,8 @@ model-select:
|
||||
support-status-header: We support both 2D and 3D models
|
||||
support-status: >
|
||||
Click {select-button} to select or import models.
|
||||
Currently, {zip} (for Live2D and Spine models) and {vrm} (for VRM models) are supported.
|
||||
Currently, {zip} (for Live2D and Spine models), {vrm} (for VRM models),
|
||||
and {mmd} (for MMD models, zipped with their textures) are supported.
|
||||
model-type-example: >
|
||||
Neuro-sama uses 2D model driven by framework developed by Live2D Inc,
|
||||
while Grok Ani (in Grok Companion) uses 3D model driven by VRM / MMD open formats.
|
||||
@@ -432,7 +433,7 @@ pages:
|
||||
delete: Delete all chat sessions
|
||||
models:
|
||||
title: Models
|
||||
description: Remove imported Live2D/VRM models.
|
||||
description: Remove imported Live2D, VRM, Spine, and MMD models.
|
||||
delete: Delete all models
|
||||
modules:
|
||||
title: Modules
|
||||
@@ -473,7 +474,7 @@ pages:
|
||||
desktop_window_centered: Desktop window moved to screen center.
|
||||
desktop_reset: Desktop data reset.
|
||||
models:
|
||||
description: Live2D, VRM, Spine, etc.
|
||||
description: Live2D, VRM, Spine, MMD, etc.
|
||||
title: Models
|
||||
sections:
|
||||
section:
|
||||
@@ -1757,6 +1758,55 @@ spine:
|
||||
fps:
|
||||
options:
|
||||
unlimited: Unlimited
|
||||
mmd:
|
||||
title: MMD Settings
|
||||
scale-and-position:
|
||||
title: Scale And Position
|
||||
scale: Scale
|
||||
x: X
|
||||
'y': 'Y'
|
||||
rotation: Rotation
|
||||
theme-color-from-model:
|
||||
title: Extract colors from model
|
||||
button-extract:
|
||||
title: Extract
|
||||
physics:
|
||||
title: Physics & Rig
|
||||
enabled: Enable physics (hair, skirt, accessories)
|
||||
ik: Enable IK (inverse kinematics)
|
||||
grant: Enable append-bone propagation
|
||||
gravity: Gravity strength
|
||||
gaze:
|
||||
title: Gaze
|
||||
tracking: Looking at
|
||||
options:
|
||||
cursor: Mouse
|
||||
camera: Camera
|
||||
none: Disabled
|
||||
animation:
|
||||
title: Animation
|
||||
idle-motion: Idle Motion
|
||||
import-motion: Import Motion (.vmd)
|
||||
play-once: Preview Animation
|
||||
remove: Remove motion
|
||||
description: >-
|
||||
Import VMD motion files, set one as the looping idle, or play any as a
|
||||
one-shot gesture. Map emotions to motions to play gestures alongside
|
||||
facial expressions.
|
||||
morphs:
|
||||
title: Morphs
|
||||
auto: (auto)
|
||||
revert: Revert to auto
|
||||
no-morphs: This model exposes no morphs.
|
||||
description: >-
|
||||
The renderer auto-detects standard Japanese morph names (あいうえお,
|
||||
まばたき). Override a slot here if your model uses non-standard names.
|
||||
materials:
|
||||
title: Materials
|
||||
no-materials: This model exposes no materials.
|
||||
description: >-
|
||||
Adjust the opacity of individual model parts (face, hair, clothing,
|
||||
accessories). Set a part to 0 to hide it.
|
||||
websocket-secure-enabled:
|
||||
title: Enable Secure WebSocket (WSS)
|
||||
description: >-
|
||||
|
||||
@@ -454,7 +454,7 @@ pages:
|
||||
desktop_window_centered: Desktop window moved to screen center.
|
||||
desktop_reset: 桌面数据重置。
|
||||
models:
|
||||
description: Live2D, VRM, Spine, etc.
|
||||
description: 切换角色的 Live2D、VRM、Spine 模型
|
||||
title: 角色模型
|
||||
sections:
|
||||
section:
|
||||
@@ -1674,7 +1674,7 @@ spine:
|
||||
mix-duration: Mix Duration (seconds)
|
||||
speed: Animation Speed
|
||||
appearance:
|
||||
title: Appearance
|
||||
title: 外观
|
||||
variant:
|
||||
title: Variant
|
||||
current-variant: Active Variant
|
||||
|
||||
@@ -454,7 +454,7 @@ pages:
|
||||
desktop_window_centered: Desktop window moved to screen center.
|
||||
desktop_reset: 桌面資料已重設
|
||||
models:
|
||||
description: Live2D, VRM, Spine, etc.
|
||||
description: 切換角色的 Live2D、VRM、Spine 模型
|
||||
title: 角色模型
|
||||
sections:
|
||||
section:
|
||||
@@ -1674,7 +1674,7 @@ spine:
|
||||
mix-duration: Mix Duration (seconds)
|
||||
speed: Animation Speed
|
||||
appearance:
|
||||
title: Appearance
|
||||
title: 外觀
|
||||
variant:
|
||||
title: Variant
|
||||
current-variant: Active Variant
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@proj-airi/server-sdk": "workspace:*",
|
||||
"@proj-airi/stage-ui": "workspace:*",
|
||||
"@proj-airi/stage-ui-live2d": "workspace:*",
|
||||
"@proj-airi/stage-ui-mmd": "workspace:*",
|
||||
"@proj-airi/stage-ui-spine": "workspace:*",
|
||||
"@proj-airi/stage-ui-three": "workspace:*",
|
||||
"@proj-airi/ui": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# @proj-airi/stage-ui-mmd
|
||||
|
||||
MMD (MikuMikuDance) scene components, composables, and stores for Project AIRI.
|
||||
|
||||
## What it does
|
||||
|
||||
Renders PMX/PMD models with VMD/VPD motion support inside AIRI's stage,
|
||||
reaching feature parity with the Live2D and VRM renderers:
|
||||
|
||||
- **Model loading** for `.pmx`/`.pmd`, from a packaged ZIP (model + textures)
|
||||
or a bare model URL.
|
||||
- **Physics** for hair, skirts, and custom rigs via Bullet (Ammo.js) — rigid
|
||||
bodies and 6-DOF spring joints, toggleable at runtime.
|
||||
- **IK** (CCD solver) and **append-bone ("grant")** propagation for standard
|
||||
and custom rigs.
|
||||
- **Importable, mappable motions**: import VMD clips (persisted to IndexedDB,
|
||||
synced across windows), set one as the looping idle, preview any as a
|
||||
one-shot, remove them, and map AIRI emotions to gesture motions.
|
||||
- **Morph-based lip-sync** driven by the shared wLipSync profile, mapping
|
||||
phonemes to the canonical あいうえお mouth morphs, with per-slot morph
|
||||
remapping for non-standard models.
|
||||
- **Emotion expressions** via morph cross-fades.
|
||||
- **Looking-at modes** (camera / cursor / disabled) with idle saccades, plus
|
||||
procedural blink.
|
||||
- **Scene settings**: model transform, camera FOV, ambient + directional
|
||||
lights, albedo glow, render scale, physics gravity, and per-material opacity
|
||||
— all live, persisted, and synced across windows.
|
||||
|
||||
It builds on [`three-stdlib`](https://github.com/pmndrs/three-stdlib) (the
|
||||
maintained TypeScript port of three.js' `examples/jsm`) for `MMDLoader`,
|
||||
`MMDAnimationHelper`, `MMDPhysics`, and `CCDIKSolver`, because upstream three
|
||||
removed the first-party MMD modules in r168. Physics uses `ammojs-typed`,
|
||||
loaded lazily so the WASM binary only ships once an MMD model is mounted.
|
||||
|
||||
## How to use
|
||||
|
||||
The package exposes the same scene contract as the other renderers, so it is
|
||||
wired through `@proj-airi/stage-ui`'s `Stage.vue` automatically when the
|
||||
selected model resolves to the `mmd` renderer:
|
||||
|
||||
```ts
|
||||
import { MMDScene, useMMD } from '@proj-airi/stage-ui-mmd'
|
||||
```
|
||||
|
||||
```vue
|
||||
<MMDScene
|
||||
v-model:state="state"
|
||||
:model-src="modelUrl"
|
||||
:cursor-position="cursorPosition"
|
||||
:current-audio-source="audioSource"
|
||||
:enable-orbit-controls="true"
|
||||
@error="onError"
|
||||
/>
|
||||
```
|
||||
|
||||
The component exposes `canvasElement()`, `captureFrame()`,
|
||||
`setEmotion(name, intensity)`, `listMorphs()`, and `listMotions()`.
|
||||
|
||||
Runtime configuration (physics/IK/grant toggles, gaze tracking, scale,
|
||||
morph overrides, emotion→motion mapping) lives in the `useMMD` Pinia store and
|
||||
is edited through the model settings panel.
|
||||
|
||||
## When to use it
|
||||
|
||||
- Displaying MMD/MikuMikuDance characters (`.pmx`/`.pmd`) on the AIRI stage.
|
||||
- When you need physics-driven secondary motion, IK, and VMD playback.
|
||||
|
||||
## When not to use it
|
||||
|
||||
- For VRM avatars use `@proj-airi/stage-ui-three`; for Live2D use
|
||||
`@proj-airi/stage-ui-live2d`; for Spine use `@proj-airi/stage-ui-spine`.
|
||||
- In environments without WebGL/WASM support (MMD physics requires Ammo WASM).
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@proj-airi/stage-ui-mmd",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "MMD (MikuMikuDance) scene components and stores for Project AIRI",
|
||||
"author": {
|
||||
"name": "Moeru AI Project AIRI Team",
|
||||
"email": "airi@moeru.ai",
|
||||
"url": "https://github.com/moeru-ai"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/moeru-ai/airi.git",
|
||||
"directory": "packages/stage-ui-mmd"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./components/scenes": "./src/components/scenes/index.ts",
|
||||
"./components/scenes/MMD.vue": "./src/components/scenes/MMD.vue",
|
||||
"./composables/mmd": "./src/composables/mmd/index.ts",
|
||||
"./constants/emotions": "./src/constants/emotions.ts",
|
||||
"./stores": "./src/stores/index.ts",
|
||||
"./stores/mmd": "./src/stores/mmd.ts",
|
||||
"./utils/mmd-loader": "./src/utils/mmd-loader.ts",
|
||||
"./utils/mmd-preview": "./src/utils/mmd-preview.ts",
|
||||
"./utils/mmd-validator": "./src/utils/mmd-validator.ts",
|
||||
"./utils/mmd-zip-loader": "./src/utils/mmd-zip-loader.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@moeru/std": "catalog:",
|
||||
"@proj-airi/stage-shared": "workspace:^",
|
||||
"@proj-airi/ui": "workspace:^",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@xsai/tool": "catalog:",
|
||||
"ammojs-typed": "catalog:",
|
||||
"culori": "catalog:",
|
||||
"es-toolkit": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"localforage": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"three": "catalog:",
|
||||
"three-stdlib": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"wlipsync": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/culori": "catalog:",
|
||||
"@types/three": "catalog:",
|
||||
"vitest": "catalog:vitest",
|
||||
"vue-tsc": "catalog:"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
export * from './scenes'
|
||||
@@ -0,0 +1,606 @@
|
||||
<script setup lang="ts">
|
||||
/*
|
||||
* Root MMD scene component.
|
||||
*
|
||||
* Unlike the VRM renderer (which is declarative via TresJS), MMD is driven
|
||||
* imperatively: MMDAnimationHelper owns the animation/IK/grant/physics step
|
||||
* and must run in a hand-managed render loop. This component owns the
|
||||
* WebGLRenderer, camera, lights, OrbitControls, and the per-frame pipeline,
|
||||
* and exposes the same contract Stage.vue expects from every renderer
|
||||
* (canvasElement / captureFrame / setEmotion).
|
||||
*/
|
||||
|
||||
import type { SkinnedMesh } from 'three'
|
||||
|
||||
import type { GazeOffset, MMDAnimationManager, MorphController } from '../../composables/mmd'
|
||||
import type { ResolvedMMDModel } from '../../utils/mmd-loader'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { Screen } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
AmbientLight,
|
||||
Box3,
|
||||
Clock,
|
||||
Color,
|
||||
DirectionalLight,
|
||||
Group,
|
||||
Mesh,
|
||||
NoToneMapping,
|
||||
PerspectiveCamera,
|
||||
Quaternion,
|
||||
Scene,
|
||||
SRGBColorSpace,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
} from 'three'
|
||||
import { OrbitControls } from 'three-stdlib'
|
||||
import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import {
|
||||
createGazeController,
|
||||
createMMDAnimationManager,
|
||||
createMMDLoaderContext,
|
||||
createMorphController,
|
||||
EYE_PITCH_LIMIT,
|
||||
EYE_YAW_LIMIT,
|
||||
loadMMDAnimationClip,
|
||||
useMMDBlink,
|
||||
useMMDEmote,
|
||||
useMMDLipSync,
|
||||
} from '../../composables/mmd'
|
||||
import { Emotion, EMOTION_VALUES } from '../../constants/emotions'
|
||||
import { useMMD } from '../../stores/mmd'
|
||||
import { loadMMDModelFromSource } from '../../utils/mmd-loader'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelSrc?: string
|
||||
modelId?: string
|
||||
paused?: boolean
|
||||
cursorPosition?: { x: number, y: number }
|
||||
currentAudioSource?: AudioBufferSourceNode
|
||||
enableOrbitControls?: boolean
|
||||
}>(), {
|
||||
paused: false,
|
||||
enableOrbitControls: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'error', err: unknown): void
|
||||
}>()
|
||||
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
|
||||
const mmdStore = useMMD()
|
||||
const {
|
||||
physicsEnabled,
|
||||
ikEnabled,
|
||||
grantEnabled,
|
||||
physicsGravity,
|
||||
gazeMode,
|
||||
position,
|
||||
scale,
|
||||
rotationY,
|
||||
morphOverrides,
|
||||
emotionActionMap,
|
||||
materialOpacity,
|
||||
idleMotionName,
|
||||
availableMotions,
|
||||
oneShotAction,
|
||||
cameraFov,
|
||||
ambientColor,
|
||||
ambientIntensity,
|
||||
directionalColor,
|
||||
directionalIntensity,
|
||||
directionalPosition,
|
||||
albedoGlow,
|
||||
renderScale,
|
||||
} = storeToRefs(mmdStore)
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
|
||||
// Imperative three.js objects (no reactivity — mutated in the render loop).
|
||||
let renderer: WebGLRenderer | undefined
|
||||
let scene: Scene | undefined
|
||||
let camera: PerspectiveCamera | undefined
|
||||
let controls: OrbitControls | undefined
|
||||
let ambientLight: AmbientLight | undefined
|
||||
let directionalLight: DirectionalLight | undefined
|
||||
let modelGroup: Group | undefined
|
||||
let resolved: ResolvedMMDModel | undefined
|
||||
let mesh: SkinnedMesh | undefined
|
||||
let morphs: MorphController | undefined
|
||||
let animation: MMDAnimationManager | undefined
|
||||
let emote: ReturnType<typeof useMMDEmote> | undefined
|
||||
// Dedicated loader for VMD motions (no textures, so no URL modifier needed),
|
||||
// plus the set of motion names already registered with the current model.
|
||||
let animationLoader: ReturnType<typeof createMMDLoaderContext> | undefined
|
||||
const registeredMotions = new Set<string>()
|
||||
const clock = new Clock()
|
||||
let rafHandle = 0
|
||||
|
||||
// Lip-sync owns Vue lifecycle hooks, so it must be created during setup. It
|
||||
// is fed the live audio source and applied to whichever morphs are mounted.
|
||||
const audioRef = shallowRef<AudioBufferSourceNode | undefined>(props.currentAudioSource)
|
||||
watch(() => props.currentAudioSource, v => audioRef.value = v)
|
||||
const lipSync = useMMDLipSync(audioRef)
|
||||
const blink = useMMDBlink()
|
||||
let gaze: ReturnType<typeof createGazeController> | undefined
|
||||
|
||||
function canvasElement() {
|
||||
return canvasRef.value
|
||||
}
|
||||
|
||||
function captureFrame(): Promise<Blob | null> | undefined {
|
||||
if (!renderer || !scene || !camera)
|
||||
return undefined
|
||||
// preserveDrawingBuffer keeps the last frame readable for the snapshot.
|
||||
renderer.render(scene, camera)
|
||||
return new Promise(resolve => canvasRef.value?.toBlob(resolve, 'image/png'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the gaze target for the current tracking mode.
|
||||
*
|
||||
* - `none` → `undefined`, so the gaze controller idle-saccades.
|
||||
* - `camera` → centered offset, so the model looks forward toward the camera.
|
||||
* - `mouse` → cursor position normalized to the canvas, or `undefined`
|
||||
* (idle saccades) when there is no cursor.
|
||||
*/
|
||||
function resolveGazeOffset(): GazeOffset | undefined {
|
||||
if (gazeMode.value === 'none')
|
||||
return undefined
|
||||
|
||||
if (gazeMode.value === 'camera') {
|
||||
// Aim the eyes at the camera: take the camera direction relative to the
|
||||
// look-at center, bring it into the model's local frame (so the user's
|
||||
// rotation is accounted for), and convert to yaw/pitch fractions of the
|
||||
// eye swing limits. Clamped, so an off-axis camera reads as a hard look.
|
||||
if (!camera || !controls || !modelGroup)
|
||||
return { x: 0, y: 0 }
|
||||
const dir = camera.position.clone().sub(controls.target).normalize()
|
||||
dir.applyQuaternion(new Quaternion().copy(modelGroup.quaternion).invert())
|
||||
const yaw = Math.atan2(dir.x, dir.z)
|
||||
const pitch = Math.asin(Math.max(-1, Math.min(1, dir.y)))
|
||||
return {
|
||||
x: Math.max(-1, Math.min(1, yaw / EYE_YAW_LIMIT)),
|
||||
y: Math.max(-1, Math.min(1, -pitch / EYE_PITCH_LIMIT)),
|
||||
}
|
||||
}
|
||||
|
||||
// mouse
|
||||
if (!props.cursorPosition || !canvasRef.value)
|
||||
return undefined
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0)
|
||||
return undefined
|
||||
const nx = ((props.cursorPosition.x - rect.left) / rect.width) * 2 - 1
|
||||
const ny = ((props.cursorPosition.y - rect.top) / rect.height) * 2 - 1
|
||||
return { x: Math.max(-1, Math.min(1, nx)), y: Math.max(-1, Math.min(1, ny)) }
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
if (!modelGroup)
|
||||
return
|
||||
modelGroup.scale.setScalar(scale.value)
|
||||
modelGroup.rotation.y = rotationY.value
|
||||
modelGroup.position.set(position.value.x, position.value.y, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* three.Color rejects 8-digit `#RRGGBBAA` hex, which the color picker emits.
|
||||
* Strip the alpha channel so light colors actually apply.
|
||||
*/
|
||||
function normalizeHex(hex: string): string {
|
||||
return /^#[0-9a-f]{8}$/i.test(hex) ? hex.slice(0, 7) : hex
|
||||
}
|
||||
|
||||
/** Sets the albedo self-glow on every material (live, from the settings store). */
|
||||
function applyMaterialGlow(value: number) {
|
||||
modelGroup?.traverse((object) => {
|
||||
if (!(object instanceof Mesh))
|
||||
return
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material]
|
||||
for (const material of materials) {
|
||||
const mat = material as { emissiveIntensity?: number }
|
||||
if (typeof mat.emissiveIntensity === 'number')
|
||||
mat.emissiveIntensity = value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Collects the model's materials as descriptors for the settings UI. */
|
||||
function collectMaterials(): { name: string, label: string, index: number }[] {
|
||||
const descriptors: { name: string, label: string, index: number }[] = []
|
||||
let index = 0
|
||||
modelGroup?.traverse((object) => {
|
||||
if (!(object instanceof Mesh))
|
||||
return
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material]
|
||||
for (const material of materials) {
|
||||
descriptors.push({ name: material.name, label: material.name || `Material ${index}`, index })
|
||||
index++
|
||||
}
|
||||
})
|
||||
return descriptors
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies per-material opacity overrides (keyed by material name). Captures
|
||||
* each material's original `transparent` flag once so restoring full opacity
|
||||
* does not force-disable a material that was authored transparent.
|
||||
*/
|
||||
function applyMaterialOpacity() {
|
||||
const overrides = materialOpacity.value
|
||||
modelGroup?.traverse((object) => {
|
||||
if (!(object instanceof Mesh))
|
||||
return
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material]
|
||||
for (const material of materials) {
|
||||
const cached = material.userData.__origTransparent
|
||||
const origTransparent = typeof cached === 'boolean'
|
||||
? cached
|
||||
: (material.userData.__origTransparent = material.transparent ?? false)
|
||||
const opacity = overrides[material.name] ?? 1
|
||||
material.opacity = opacity
|
||||
material.transparent = origTransparent || opacity < 1
|
||||
material.needsUpdate = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setupScene() {
|
||||
const canvas = canvasRef.value!
|
||||
renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, preserveDrawingBuffer: true })
|
||||
renderer.outputColorSpace = SRGBColorSpace
|
||||
// MMD toon materials are not PBR/HDR; filmic tone mapping desaturates and
|
||||
// washes them out, so render their colors directly.
|
||||
renderer.toneMapping = NoToneMapping
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2) * renderScale.value)
|
||||
|
||||
scene = new Scene()
|
||||
camera = new PerspectiveCamera(cameraFov.value, 1, 0.1, 1000)
|
||||
camera.position.set(0, 1, 3)
|
||||
|
||||
// Toon shading with an albedo self-glow (applied at load): keep direct lights
|
||||
// moderate so the lit side doesn't blow out, while the glow + ambient keep the
|
||||
// shadow side bright. All values are user-adjustable via the settings store.
|
||||
ambientLight = new AmbientLight(new Color(normalizeHex(ambientColor.value)), ambientIntensity.value)
|
||||
scene.add(ambientLight)
|
||||
directionalLight = new DirectionalLight(new Color(normalizeHex(directionalColor.value)), directionalIntensity.value)
|
||||
directionalLight.position.set(directionalPosition.value.x, directionalPosition.value.y, directionalPosition.value.z)
|
||||
scene.add(directionalLight)
|
||||
|
||||
controls = new OrbitControls(camera, canvas)
|
||||
controls.enableDamping = true
|
||||
controls.enabled = props.enableOrbitControls
|
||||
}
|
||||
|
||||
/** Frames the camera so the whole model fits the viewport, and centers it. */
|
||||
function frameCamera() {
|
||||
if (!camera || !controls || !modelGroup)
|
||||
return
|
||||
modelGroup.updateMatrixWorld(true)
|
||||
const box = new Box3().setFromObject(modelGroup)
|
||||
if (box.isEmpty())
|
||||
return
|
||||
const size = box.getSize(new Vector3())
|
||||
const center = box.getCenter(new Vector3())
|
||||
|
||||
// Fit to BOTH axes using the camera aspect, so the model fills a portrait or
|
||||
// landscape viewport without being cropped (mirrors Spine's auto-fit).
|
||||
const vFov = (camera.fov * Math.PI) / 180
|
||||
const fitHeightDistance = (size.y / 2) / Math.tan(vFov / 2)
|
||||
const fitWidthDistance = (size.x / 2) / (Math.tan(vFov / 2) * camera.aspect)
|
||||
const distance = 1.2 * Math.max(fitHeightDistance, fitWidthDistance)
|
||||
|
||||
camera.position.set(center.x, center.y, center.z + distance)
|
||||
camera.near = Math.max(distance / 100, 0.01)
|
||||
camera.far = distance * 100
|
||||
camera.updateProjectionMatrix()
|
||||
camera.lookAt(center)
|
||||
controls.target.copy(center)
|
||||
controls.update()
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!renderer || !camera || !canvasRef.value)
|
||||
return
|
||||
const w = canvasRef.value.clientWidth
|
||||
const h = canvasRef.value.clientHeight
|
||||
if (w === 0 || h === 0)
|
||||
return
|
||||
renderer.setSize(w, h, false)
|
||||
camera.aspect = w / h
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
rafHandle = requestAnimationFrame(renderLoop)
|
||||
const delta = clock.getDelta()
|
||||
|
||||
if (props.paused || !renderer || !scene || !camera)
|
||||
return
|
||||
|
||||
if (animation) {
|
||||
// One imperative step: animation mixer → IK → grant → physics.
|
||||
animation.update(delta)
|
||||
// Apply AIRI-owned morphs after the helper so lip-sync/expression win
|
||||
// over any VMD mouth/expression keyframes.
|
||||
emote?.update(delta)
|
||||
blink.update(morphs, delta)
|
||||
lipSync.update(morphs, delta)
|
||||
// Gaze rotates eye/head bones, also after the helper.
|
||||
gaze?.update(resolveGazeOffset(), delta)
|
||||
}
|
||||
|
||||
controls?.update()
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
function disposeModel() {
|
||||
if (animation) {
|
||||
animation.dispose()
|
||||
animation = undefined
|
||||
}
|
||||
if (modelGroup && scene) {
|
||||
scene.remove(modelGroup)
|
||||
modelGroup.traverse((obj) => {
|
||||
if (obj instanceof Mesh) {
|
||||
obj.geometry?.dispose?.()
|
||||
const material = obj.material
|
||||
if (Array.isArray(material))
|
||||
material.forEach(m => m.dispose())
|
||||
else
|
||||
material?.dispose?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
resolved?.dispose()
|
||||
registeredMotions.clear()
|
||||
animationLoader = undefined
|
||||
modelGroup = undefined
|
||||
mesh = undefined
|
||||
morphs = undefined
|
||||
emote = undefined
|
||||
gaze = undefined
|
||||
resolved = undefined
|
||||
mmdStore.isModelLoaded = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and registers any imported VMD motions not yet bound to the current
|
||||
* model, then (re)applies the selected idle motion. Safe to call repeatedly;
|
||||
* already-registered motions are skipped.
|
||||
*/
|
||||
async function syncMotions() {
|
||||
if (!animation || !mesh)
|
||||
return
|
||||
|
||||
for (const descriptor of availableMotions.value) {
|
||||
if (registeredMotions.has(descriptor.name))
|
||||
continue
|
||||
try {
|
||||
// The VMD file lives in IndexedDB (shared across windows); build a
|
||||
// window-local object URL to load it, then revoke it once parsed.
|
||||
const file = await mmdStore.getMotionFile(descriptor.id)
|
||||
if (!file) {
|
||||
console.warn(`[mmd] motion file "${descriptor.name}" (${descriptor.id}) not found in storage`)
|
||||
continue
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
try {
|
||||
animationLoader ??= createMMDLoaderContext()
|
||||
const clip = await loadMMDAnimationClip(animationLoader.loader, url, mesh)
|
||||
animation.registerClip(descriptor.name, clip)
|
||||
registeredMotions.add(descriptor.name)
|
||||
if (clip.tracks.length === 0) {
|
||||
console.warn(
|
||||
`[mmd] motion "${descriptor.name}" loaded but has 0 tracks matching this model. `
|
||||
+ 'The VMD\'s bone/morph names likely do not match the model (different rig/naming).',
|
||||
)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[mmd] failed to load motion', descriptor.name, errorMessageFrom(err))
|
||||
emit('error', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (idleMotionName.value && registeredMotions.has(idleMotionName.value))
|
||||
animation.setIdleMotion(idleMotionName.value)
|
||||
}
|
||||
|
||||
async function loadModel(src: string) {
|
||||
if (!scene)
|
||||
return
|
||||
|
||||
componentState.value = 'loading'
|
||||
disposeModel()
|
||||
|
||||
try {
|
||||
resolved = await loadMMDModelFromSource(src)
|
||||
mesh = resolved.mesh
|
||||
|
||||
modelGroup = new Group()
|
||||
modelGroup.add(mesh)
|
||||
applyTransform()
|
||||
scene.add(modelGroup)
|
||||
|
||||
morphs = createMorphController(mesh, morphOverrides.value)
|
||||
mmdStore.availableMorphs = morphs.availableMorphs
|
||||
if (!morphs.resolvedSlots.some(slot => slot.startsWith('vowel'))) {
|
||||
console.warn(
|
||||
'[mmd] no vowel mouth morphs (あ/い/う/え/お) resolved; lip-sync cannot move the mouth. '
|
||||
+ 'Available morphs:',
|
||||
morphs.availableMorphs,
|
||||
)
|
||||
}
|
||||
|
||||
emote = useMMDEmote(morphs)
|
||||
gaze = createGazeController(mesh)
|
||||
|
||||
animation = createMMDAnimationManager(mesh, { physicsEnabled: physicsEnabled.value })
|
||||
// No preset idle VMD ships yet; init with an empty clip so physics/IK run.
|
||||
await animation.init()
|
||||
animation.setIKEnabled(ikEnabled.value)
|
||||
animation.setGrantEnabled(grantEnabled.value)
|
||||
animation.setGravity(physicsGravity.value)
|
||||
|
||||
// Ensure the camera aspect matches the live canvas before fitting.
|
||||
resize()
|
||||
frameCamera()
|
||||
applyMaterialGlow(albedoGlow.value)
|
||||
mmdStore.availableMaterials = collectMaterials()
|
||||
applyMaterialOpacity()
|
||||
|
||||
mmdStore.isModelLoaded = true
|
||||
componentState.value = 'mounted'
|
||||
|
||||
// Bind any motions imported before this model mounted.
|
||||
await syncMotions()
|
||||
}
|
||||
catch (err) {
|
||||
componentState.value = 'pending'
|
||||
console.error('[mmd] failed to load model:', errorMessageFrom(err))
|
||||
emit('error', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Plays the gesture motion mapped to an emotion, if the model has one. */
|
||||
function setEmotion(emotion: string, intensity = 1) {
|
||||
const value = EMOTION_VALUES.includes(emotion as Emotion) ? emotion as Emotion : Emotion.Neutral
|
||||
emote?.setEmotion(value, intensity)
|
||||
|
||||
const actionName = emotionActionMap.value[value]
|
||||
if (actionName)
|
||||
animation?.playAction(actionName, { loop: false })
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
onMounted(() => {
|
||||
setupScene()
|
||||
resizeObserver = new ResizeObserver(() => resize())
|
||||
if (canvasRef.value)
|
||||
resizeObserver.observe(canvasRef.value)
|
||||
resize()
|
||||
clock.start()
|
||||
renderLoop()
|
||||
|
||||
if (props.modelSrc)
|
||||
loadModel(props.modelSrc)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(rafHandle)
|
||||
resizeObserver?.disconnect()
|
||||
disposeModel()
|
||||
controls?.dispose()
|
||||
if (renderer) {
|
||||
renderer.dispose()
|
||||
renderer.forceContextLoss()
|
||||
}
|
||||
scene = undefined
|
||||
camera = undefined
|
||||
renderer = undefined
|
||||
controls = undefined
|
||||
ambientLight = undefined
|
||||
directionalLight = undefined
|
||||
})
|
||||
|
||||
watch(() => props.modelSrc, (src) => {
|
||||
if (src)
|
||||
loadModel(src)
|
||||
else
|
||||
disposeModel()
|
||||
})
|
||||
|
||||
watch(() => props.enableOrbitControls, (enabled) => {
|
||||
if (controls)
|
||||
controls.enabled = enabled
|
||||
})
|
||||
|
||||
// View transform sliders.
|
||||
watch([scale, rotationY, () => position.value.x, () => position.value.y], () => applyTransform())
|
||||
|
||||
// Solver/physics toggles.
|
||||
watch(physicsEnabled, v => animation?.setPhysicsEnabled(v))
|
||||
watch(ikEnabled, v => animation?.setIKEnabled(v))
|
||||
watch(grantEnabled, v => animation?.setGrantEnabled(v))
|
||||
watch(physicsGravity, v => animation?.setGravity(v))
|
||||
|
||||
// Morph-slot overrides: rebind each slot the user remapped (empty = auto).
|
||||
watch(morphOverrides, (overrides) => {
|
||||
if (!morphs)
|
||||
return
|
||||
for (const [slot, name] of Object.entries(overrides))
|
||||
morphs.override(slot as Parameters<MorphController['override']>[0], name ?? '')
|
||||
}, { deep: true })
|
||||
|
||||
// One-shot motion requests from tools/the act bus.
|
||||
watch(oneShotAction, (request) => {
|
||||
if (request)
|
||||
animation?.playAction(request.name, { loop: request.loop })
|
||||
})
|
||||
|
||||
// Newly imported VMD motions: load and register them against the live model.
|
||||
watch(availableMotions, () => {
|
||||
void syncMotions()
|
||||
}, { deep: true })
|
||||
|
||||
// Idle-motion selection from the settings panel.
|
||||
watch(idleMotionName, (name) => {
|
||||
if (name && registeredMotions.has(name))
|
||||
animation?.setIdleMotion(name)
|
||||
})
|
||||
|
||||
// Scene settings — lighting.
|
||||
watch([ambientColor, ambientIntensity], () => {
|
||||
if (!ambientLight)
|
||||
return
|
||||
ambientLight.color.set(normalizeHex(ambientColor.value))
|
||||
ambientLight.intensity = ambientIntensity.value
|
||||
})
|
||||
watch([directionalColor, directionalIntensity], () => {
|
||||
if (!directionalLight)
|
||||
return
|
||||
directionalLight.color.set(normalizeHex(directionalColor.value))
|
||||
directionalLight.intensity = directionalIntensity.value
|
||||
})
|
||||
watch(directionalPosition, () => {
|
||||
directionalLight?.position.set(directionalPosition.value.x, directionalPosition.value.y, directionalPosition.value.z)
|
||||
}, { deep: true })
|
||||
|
||||
// Scene settings — camera & rendering.
|
||||
watch(cameraFov, () => {
|
||||
if (!camera)
|
||||
return
|
||||
camera.fov = cameraFov.value
|
||||
camera.updateProjectionMatrix()
|
||||
})
|
||||
watch(renderScale, () => {
|
||||
renderer?.setPixelRatio(Math.min(window.devicePixelRatio, 2) * renderScale.value)
|
||||
resize()
|
||||
})
|
||||
watch(albedoGlow, () => applyMaterialGlow(albedoGlow.value))
|
||||
watch(materialOpacity, () => applyMaterialOpacity(), { deep: true })
|
||||
|
||||
defineExpose({
|
||||
canvasElement,
|
||||
captureFrame,
|
||||
setEmotion,
|
||||
listMorphs: () => morphs?.availableMorphs ?? [],
|
||||
listMotions: () => animation?.availableClips() ?? [],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Screen relative>
|
||||
<canvas ref="canvasRef" h-full w-full />
|
||||
</Screen>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as MMD } from './MMD.vue'
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { AnimationAction, AnimationClip, AnimationMixer, SkinnedMesh } from 'three'
|
||||
|
||||
import { AnimationClip as AnimationClipCtor, LoopOnce, LoopRepeat, Vector3 } from 'three'
|
||||
import { MMDAnimationHelper } from 'three-stdlib'
|
||||
|
||||
import { ensureAmmo } from '../../utils/ammo'
|
||||
|
||||
const DEFAULT_CROSSFADE = 0.4
|
||||
|
||||
export interface MMDAnimationManagerOptions {
|
||||
/** Initial physics enablement. Ammo always loads so it can be toggled later. */
|
||||
physicsEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface PlayActionOptions {
|
||||
/** Loop the action instead of reverting to idle when it finishes. */
|
||||
loop?: boolean
|
||||
/** Cross-fade duration in seconds. */
|
||||
crossfade?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the per-model {@link MMDAnimationHelper} and the catalog of importable
|
||||
* VMD motions, exposing a play/crossfade API and physics/IK/grant toggles.
|
||||
*
|
||||
* Design:
|
||||
* - The helper auto-plays whatever clip it is constructed with, so we hand it
|
||||
* only the idle clip (or an empty placeholder so a mixer always exists for
|
||||
* physics warmup) and layer every other motion on the same mixer ourselves.
|
||||
* - Physics is created up front (it cannot be added after the fact) and merely
|
||||
* toggled via `helper.enable('physics', …)`, so Ammo is required before the
|
||||
* mesh is added. Ammo is still lazy at the app level: it only loads once an
|
||||
* MMD model is actually mounted.
|
||||
* - One-shot actions register a `finished` listener that fades back to idle,
|
||||
* mirroring how the Spine manager layers emotion clips over the idle track.
|
||||
*
|
||||
* `update(delta)` must be called once per frame; it drives animation, IK,
|
||||
* append-bone (grant) propagation, and the physics simulation in one step.
|
||||
*/
|
||||
export function createMMDAnimationManager(mesh: SkinnedMesh, options: MMDAnimationManagerOptions = {}) {
|
||||
// NOTICE:
|
||||
// resetPhysicsOnLoop must stay false. When true, MMDAnimationHelper calls
|
||||
// physics.reset() every time the mixer's clip loops, which snaps every rigid
|
||||
// body back to its bone pose and re-seeds the sim — a visible periodic
|
||||
// "fling" of hair/skirt. Continuous simulation looks correct for an idle
|
||||
// character and avoids the jolt.
|
||||
const helper = new MMDAnimationHelper({ afterglow: 2.0, resetPhysicsOnLoop: false })
|
||||
const registry = new Map<string, AnimationClip>()
|
||||
|
||||
let mixer: AnimationMixer | undefined
|
||||
let idleClip: AnimationClip | undefined
|
||||
let idleAction: AnimationAction | undefined
|
||||
let currentAction: AnimationAction | undefined
|
||||
let initialized = false
|
||||
|
||||
function getMixer(): AnimationMixer | undefined {
|
||||
if (!mixer)
|
||||
mixer = helper.objects.get(mesh)?.mixer
|
||||
return mixer
|
||||
}
|
||||
|
||||
function revertToIdleOnFinish(action: AnimationAction) {
|
||||
const m = getMixer()
|
||||
if (!m)
|
||||
return
|
||||
const onFinished = (event: { action: AnimationAction }) => {
|
||||
if (event.action !== action)
|
||||
return
|
||||
m.removeEventListener('finished', onFinished)
|
||||
playIdle()
|
||||
}
|
||||
m.addEventListener('finished', onFinished)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the helper, physics, IK, and grant solvers for the mesh.
|
||||
*
|
||||
* `idle` is the persistent looping motion (optional). Ammo is initialized
|
||||
* before the mesh is added so the physics world can be constructed.
|
||||
*/
|
||||
async function init(idle?: AnimationClip): Promise<void> {
|
||||
if (initialized)
|
||||
return
|
||||
|
||||
await ensureAmmo()
|
||||
|
||||
// The helper needs at least one clip to create a mixer (required for action
|
||||
// playback). When there is no real idle motion we use a long, track-less
|
||||
// placeholder: a zero-length clip would fire the mixer's "loop" event every
|
||||
// frame, so the large duration keeps it from ever looping.
|
||||
idleClip = idle ?? new AnimationClipCtor('__mmd_empty__', Number.MAX_SAFE_INTEGER, [])
|
||||
|
||||
helper.add(mesh, {
|
||||
animation: idleClip,
|
||||
physics: true,
|
||||
})
|
||||
|
||||
helper.enable('physics', options.physicsEnabled ?? true)
|
||||
|
||||
mixer = helper.objects.get(mesh)?.mixer
|
||||
if (mixer && idle)
|
||||
idleAction = mixer.existingAction(idleClip) ?? undefined
|
||||
currentAction = idleAction
|
||||
initialized = true
|
||||
}
|
||||
|
||||
/** Registers a VMD-derived clip under a name for later playback. */
|
||||
function registerClip(name: string, clip: AnimationClip): void {
|
||||
registry.set(name, clip)
|
||||
}
|
||||
|
||||
/** Names of all registered motions, for settings UIs and action mapping. */
|
||||
function availableClips(): string[] {
|
||||
return Array.from(registry.keys())
|
||||
}
|
||||
|
||||
/** Cross-fades back to the persistent idle loop. */
|
||||
function playIdle(crossfade = DEFAULT_CROSSFADE): void {
|
||||
const m = getMixer()
|
||||
if (!m)
|
||||
return
|
||||
|
||||
// No idle clip registered (e.g. empty placeholder): just fade the current
|
||||
// motion out so bones relax to rest instead of clamping on the last frame.
|
||||
if (!idleAction) {
|
||||
if (currentAction)
|
||||
currentAction.fadeOut(crossfade)
|
||||
currentAction = undefined
|
||||
return
|
||||
}
|
||||
|
||||
if (currentAction && currentAction !== idleAction)
|
||||
currentAction.fadeOut(crossfade)
|
||||
// Restore LoopRepeat: a one-shot may have reused this same action with
|
||||
// LoopOnce, which would otherwise leave the idle no longer looping.
|
||||
idleAction.reset().setLoop(LoopRepeat, Number.POSITIVE_INFINITY).setEffectiveWeight(1).fadeIn(crossfade).play()
|
||||
currentAction = idleAction
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a registered motion, cross-fading from the current one. One-shots
|
||||
* revert to idle on completion; looping motions stay until replaced.
|
||||
*
|
||||
* Returns `false` when the name is not registered so callers can fall back.
|
||||
*/
|
||||
function playAction(name: string, opts: PlayActionOptions = {}): boolean {
|
||||
const m = getMixer()
|
||||
const clip = registry.get(name)
|
||||
if (!m || !clip) {
|
||||
console.warn(`[mmd] playAction skipped: "${name}" is ${clip ? 'present' : 'not registered'}, mixer ${m ? 'ready' : 'missing'}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const loop = opts.loop ?? false
|
||||
const crossfade = opts.crossfade ?? DEFAULT_CROSSFADE
|
||||
|
||||
const action = m.clipAction(clip)
|
||||
action.reset()
|
||||
action.setLoop(loop ? LoopRepeat : LoopOnce, loop ? Number.POSITIVE_INFINITY : 1)
|
||||
action.clampWhenFinished = !loop
|
||||
action.setEffectiveWeight(1)
|
||||
action.fadeIn(crossfade).play()
|
||||
|
||||
if (currentAction && currentAction !== action)
|
||||
currentAction.fadeOut(crossfade)
|
||||
currentAction = action
|
||||
|
||||
if (!loop)
|
||||
revertToIdleOnFinish(action)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a registered motion the persistent looping base (the idle the
|
||||
* character returns to). Cross-fades from whatever is currently playing.
|
||||
*
|
||||
* Returns `false` when the name is not registered.
|
||||
*/
|
||||
function setIdleMotion(name: string, crossfade = DEFAULT_CROSSFADE): boolean {
|
||||
const m = getMixer()
|
||||
const clip = registry.get(name)
|
||||
if (!m || !clip) {
|
||||
console.warn(`[mmd] setIdleMotion skipped: "${name}" is ${clip ? 'present' : 'not registered'}, mixer ${m ? 'ready' : 'missing'}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const action = m.clipAction(clip)
|
||||
action.reset()
|
||||
action.setLoop(LoopRepeat, Number.POSITIVE_INFINITY)
|
||||
action.clampWhenFinished = false
|
||||
action.setEffectiveWeight(1)
|
||||
action.fadeIn(crossfade).play()
|
||||
|
||||
const previous = idleAction
|
||||
idleClip = clip
|
||||
idleAction = action
|
||||
if (currentAction && currentAction !== action)
|
||||
currentAction.fadeOut(crossfade)
|
||||
else if (previous && previous !== action)
|
||||
previous.fadeOut(crossfade)
|
||||
currentAction = action
|
||||
return true
|
||||
}
|
||||
|
||||
function setPhysicsEnabled(enabled: boolean): void {
|
||||
helper.enable('physics', enabled)
|
||||
}
|
||||
|
||||
/** Sets the physics world gravity strength, applied as (0, -magnitude, 0). */
|
||||
function setGravity(magnitude: number): void {
|
||||
helper.objects.get(mesh)?.physics?.setGravity(new Vector3(0, -magnitude, 0))
|
||||
}
|
||||
|
||||
function setIKEnabled(enabled: boolean): void {
|
||||
helper.enable('ik', enabled)
|
||||
}
|
||||
|
||||
function setGrantEnabled(enabled: boolean): void {
|
||||
helper.enable('grant', enabled)
|
||||
}
|
||||
|
||||
function update(delta: number): void {
|
||||
if (!initialized)
|
||||
return
|
||||
helper.update(delta)
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
const m = getMixer()
|
||||
m?.stopAllAction()
|
||||
if (initialized) {
|
||||
try {
|
||||
helper.remove(mesh)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
registry.clear()
|
||||
mixer = undefined
|
||||
idleAction = undefined
|
||||
currentAction = undefined
|
||||
initialized = false
|
||||
}
|
||||
|
||||
return {
|
||||
helper,
|
||||
init,
|
||||
registerClip,
|
||||
availableClips,
|
||||
playIdle,
|
||||
playAction,
|
||||
setIdleMotion,
|
||||
setPhysicsEnabled,
|
||||
setGravity,
|
||||
setIKEnabled,
|
||||
setGrantEnabled,
|
||||
update,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
export type MMDAnimationManager = ReturnType<typeof createMMDAnimationManager>
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { MorphController } from './morph'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
const BLINK_DURATION = 0.2 // seconds for one open→close→open cycle
|
||||
const MIN_BLINK_INTERVAL = 1
|
||||
const MAX_BLINK_INTERVAL = 6
|
||||
|
||||
function nextInterval() {
|
||||
return Math.random() * (MAX_BLINK_INTERVAL - MIN_BLINK_INTERVAL) + MIN_BLINK_INTERVAL
|
||||
}
|
||||
|
||||
/**
|
||||
* Procedural eye blinking via the model's blink morph.
|
||||
*
|
||||
* Identical timing model to the VRM renderer's `useBlink`: a randomized
|
||||
* 1–6 s interval between blinks and a 0.2 s sine-shaped close/open so the lid
|
||||
* motion is smooth rather than a hard on/off.
|
||||
*/
|
||||
export function useMMDBlink() {
|
||||
const isBlinking = ref(false)
|
||||
const blinkProgress = ref(0)
|
||||
const timeSinceLastBlink = ref(0)
|
||||
const nextBlinkTime = ref(nextInterval())
|
||||
|
||||
function update(morphs: MorphController | undefined, delta: number) {
|
||||
if (!morphs)
|
||||
return
|
||||
|
||||
timeSinceLastBlink.value += delta
|
||||
|
||||
if (!isBlinking.value && timeSinceLastBlink.value >= nextBlinkTime.value) {
|
||||
isBlinking.value = true
|
||||
blinkProgress.value = 0
|
||||
}
|
||||
|
||||
if (!isBlinking.value)
|
||||
return
|
||||
|
||||
blinkProgress.value += delta / BLINK_DURATION
|
||||
morphs.set('blink', Math.sin(Math.PI * blinkProgress.value))
|
||||
|
||||
if (blinkProgress.value >= 1) {
|
||||
isBlinking.value = false
|
||||
timeSinceLastBlink.value = 0
|
||||
morphs.set('blink', 0)
|
||||
nextBlinkTime.value = nextInterval()
|
||||
}
|
||||
}
|
||||
|
||||
return { update }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { MorphSlot } from '../../constants/morphs'
|
||||
import type { MorphController } from './morph'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { Emotion } from '../../constants/emotions'
|
||||
import { EMOTION_MORPHS } from '../../constants/morphs'
|
||||
|
||||
function lerp(start: number, end: number, t: number): number {
|
||||
return start + (end - start) * t
|
||||
}
|
||||
|
||||
function easeInOutCubic(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2
|
||||
}
|
||||
|
||||
function clampIntensity(value: number): number {
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
/** All emotion-related morph slots, so transitions can zero the untargeted ones. */
|
||||
const EXPRESSION_SLOTS: MorphSlot[] = ['smile', 'anger', 'sad', 'surprise', 'troubled', 'serious']
|
||||
|
||||
/**
|
||||
* Emotion-driven facial morphs for MMD models.
|
||||
*
|
||||
* Mirrors the VRM emote composable: each emotion declares target morph
|
||||
* weights and a blend duration, and `update(delta)` cross-fades from the
|
||||
* currently displayed weights to the target with an ease-in-out curve so
|
||||
* emotion changes never snap.
|
||||
*
|
||||
* Lip-sync owns the vowel slots, so emotions only touch expression slots
|
||||
* (smile/anger/...) plus, for a few emotions, a small fixed vowel accent that
|
||||
* lip-sync overrides as soon as speech starts.
|
||||
*/
|
||||
export function useMMDEmote(morphs: MorphController) {
|
||||
const currentEmotion = ref<Emotion | null>(null)
|
||||
const isTransitioning = ref(false)
|
||||
const transitionProgress = ref(0)
|
||||
const startWeights = new Map<MorphSlot, number>()
|
||||
const targetWeights = new Map<MorphSlot, number>()
|
||||
let resetTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function clearResetTimeout() {
|
||||
if (resetTimeout) {
|
||||
clearTimeout(resetTimeout)
|
||||
resetTimeout = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function setEmotion(emotion: Emotion, intensity = 1) {
|
||||
clearResetTimeout()
|
||||
|
||||
const state = EMOTION_MORPHS[emotion]
|
||||
if (!state) {
|
||||
console.warn(`[mmd] emotion ${emotion} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
currentEmotion.value = emotion
|
||||
isTransitioning.value = true
|
||||
transitionProgress.value = 0
|
||||
startWeights.clear()
|
||||
targetWeights.clear()
|
||||
|
||||
const normalized = clampIntensity(intensity)
|
||||
|
||||
// Capture where each expression slot currently sits so the cross-fade
|
||||
// starts from the live value instead of snapping to zero first.
|
||||
for (const slot of EXPRESSION_SLOTS) {
|
||||
startWeights.set(slot, morphs.get(slot))
|
||||
targetWeights.set(slot, 0)
|
||||
}
|
||||
|
||||
for (const influence of state.influences) {
|
||||
startWeights.set(influence.slot, morphs.get(influence.slot))
|
||||
targetWeights.set(influence.slot, influence.value * normalized)
|
||||
}
|
||||
}
|
||||
|
||||
function setEmotionWithResetAfter(emotion: Emotion, ms: number, intensity = 1) {
|
||||
clearResetTimeout()
|
||||
setEmotion(emotion, intensity)
|
||||
resetTimeout = setTimeout(() => {
|
||||
setEmotion(Emotion.Neutral)
|
||||
resetTimeout = undefined
|
||||
}, ms)
|
||||
}
|
||||
|
||||
function update(delta: number) {
|
||||
if (!isTransitioning.value || !currentEmotion.value)
|
||||
return
|
||||
|
||||
const blendDuration = EMOTION_MORPHS[currentEmotion.value].blendDuration || 0.3
|
||||
transitionProgress.value += delta / blendDuration
|
||||
if (transitionProgress.value >= 1) {
|
||||
transitionProgress.value = 1
|
||||
isTransitioning.value = false
|
||||
}
|
||||
|
||||
const t = easeInOutCubic(transitionProgress.value)
|
||||
for (const [slot, target] of targetWeights) {
|
||||
const start = startWeights.get(slot) ?? 0
|
||||
morphs.set(slot, lerp(start, target, t))
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
clearResetTimeout()
|
||||
}
|
||||
|
||||
return {
|
||||
currentEmotion,
|
||||
isTransitioning,
|
||||
setEmotion,
|
||||
setEmotionWithResetAfter,
|
||||
update,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Bone, SkinnedMesh } from 'three'
|
||||
|
||||
import { Euler, MathUtils, Quaternion } from 'three'
|
||||
|
||||
/** Normalized screen-space gaze offset, each axis in roughly [-1, 1]. */
|
||||
export interface GazeOffset {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
// Eyes can swing further than the head; values in radians.
|
||||
export const EYE_YAW_LIMIT = 0.35
|
||||
export const EYE_PITCH_LIMIT = 0.25
|
||||
const HEAD_YAW_LIMIT = 0.2
|
||||
const HEAD_PITCH_LIMIT = 0.12
|
||||
|
||||
// Idle saccade tuning — small, infrequent darts when nothing is tracked.
|
||||
const SACCADE_MIN_MS = 400
|
||||
const SACCADE_MAX_MS = 2200
|
||||
const SACCADE_RANGE = 0.4
|
||||
|
||||
// Candidate bone names (semi-standard MMD first, then common English exports).
|
||||
const LEFT_EYE_NAMES = ['左目', 'eye_L', 'EyeLeft', 'LeftEye']
|
||||
const RIGHT_EYE_NAMES = ['右目', 'eye_R', 'EyeRight', 'RightEye']
|
||||
const BOTH_EYES_NAMES = ['両目', 'Eyes']
|
||||
const HEAD_NAMES = ['頭', 'head', 'Head']
|
||||
|
||||
function findBone(mesh: SkinnedMesh, names: string[]): Bone | undefined {
|
||||
for (const name of names) {
|
||||
const bone = mesh.skeleton.bones.find(b => b.name === name)
|
||||
if (bone)
|
||||
return bone
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface GazeBone {
|
||||
bone: Bone
|
||||
rest: Quaternion
|
||||
}
|
||||
|
||||
export interface GazeController {
|
||||
/** Drives gaze toward a normalized offset; pass `undefined` to idle-saccade. */
|
||||
update: (offset: GazeOffset | undefined, delta: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bone-based eye gaze and subtle head aim for MMD models.
|
||||
*
|
||||
* VRM exposes a first-class `lookAt`; MMD does not, so we rotate the eye bones
|
||||
* and add a damped fraction of the same aim to the head bone for a natural
|
||||
* follow. Rotations are applied relative to each bone's rest pose and must run
|
||||
* after `MMDAnimationHelper.update()` so they layer on top of the active
|
||||
* motion.
|
||||
*
|
||||
* We rotate the actual `左目`/`右目` eye bones (which the eyeballs are skinned
|
||||
* to) rather than the `両目` control bone. `両目` drives the eyes through the
|
||||
* append/grant solver, which runs *inside* `helper.update()`; rotating it
|
||||
* afterward would be too late and the eyes would not move. `両目` is used only
|
||||
* as a fallback when a model lacks separate eye bones.
|
||||
*
|
||||
* When no tracking target is supplied the controller produces small idle
|
||||
* saccades, matching the VRM renderer's resting-eye behavior.
|
||||
*/
|
||||
export function createGazeController(mesh: SkinnedMesh): GazeController {
|
||||
const eyeBones: GazeBone[] = []
|
||||
const left = findBone(mesh, LEFT_EYE_NAMES)
|
||||
const right = findBone(mesh, RIGHT_EYE_NAMES)
|
||||
if (left)
|
||||
eyeBones.push({ bone: left, rest: left.quaternion.clone() })
|
||||
if (right)
|
||||
eyeBones.push({ bone: right, rest: right.quaternion.clone() })
|
||||
|
||||
// Fallback: a single both-eyes bone the eyeballs are skinned to directly.
|
||||
if (eyeBones.length === 0) {
|
||||
const both = findBone(mesh, BOTH_EYES_NAMES)
|
||||
if (both)
|
||||
eyeBones.push({ bone: both, rest: both.quaternion.clone() })
|
||||
}
|
||||
|
||||
if (eyeBones.length === 0) {
|
||||
console.warn(
|
||||
'[mmd] gaze: no eye bone matched (左目/右目/両目); eye tracking disabled. '
|
||||
+ 'Bones containing 目/eye:',
|
||||
mesh.skeleton.bones.filter(b => /目|eye/i.test(b.name)).map(b => b.name),
|
||||
)
|
||||
}
|
||||
|
||||
const headBoneRaw = findBone(mesh, HEAD_NAMES)
|
||||
const headBone: GazeBone | undefined = headBoneRaw
|
||||
? { bone: headBoneRaw, rest: headBoneRaw.quaternion.clone() }
|
||||
: undefined
|
||||
|
||||
// Smoothed current aim, lerped toward the target each frame.
|
||||
const current: GazeOffset = { x: 0, y: 0 }
|
||||
const scratchEuler = new Euler()
|
||||
const scratchQuat = new Quaternion()
|
||||
|
||||
// Idle saccade state.
|
||||
let saccade: GazeOffset = { x: 0, y: 0 }
|
||||
let nextSaccadeIn = 0
|
||||
|
||||
function applyBone(target: GazeBone, yaw: number, pitch: number) {
|
||||
// Yaw about Y, pitch about X, relative to the bone's rest pose.
|
||||
scratchEuler.set(pitch, yaw, 0, 'XYZ')
|
||||
scratchQuat.setFromEuler(scratchEuler)
|
||||
target.bone.quaternion.copy(target.rest).multiply(scratchQuat)
|
||||
}
|
||||
|
||||
return {
|
||||
update(offset, delta) {
|
||||
let desired = offset
|
||||
if (!desired) {
|
||||
nextSaccadeIn -= delta * 1000
|
||||
if (nextSaccadeIn <= 0) {
|
||||
saccade = {
|
||||
x: MathUtils.randFloat(-SACCADE_RANGE, SACCADE_RANGE),
|
||||
y: MathUtils.randFloat(-SACCADE_RANGE, SACCADE_RANGE),
|
||||
}
|
||||
nextSaccadeIn = MathUtils.randFloat(SACCADE_MIN_MS, SACCADE_MAX_MS)
|
||||
}
|
||||
desired = saccade
|
||||
}
|
||||
|
||||
// Critically-damped-ish approach; frame-rate independent.
|
||||
const rate = 1 - Math.exp(-10 * delta)
|
||||
current.x += (desired.x - current.x) * rate
|
||||
current.y += (desired.y - current.y) * rate
|
||||
|
||||
// Positive cursor-x (right) turns gaze toward screen-right; positive
|
||||
// cursor-y (down) looks down.
|
||||
for (const eye of eyeBones)
|
||||
applyBone(eye, current.x * EYE_YAW_LIMIT, current.y * EYE_PITCH_LIMIT)
|
||||
|
||||
if (headBone)
|
||||
applyBone(headBone, current.x * HEAD_YAW_LIMIT, current.y * HEAD_PITCH_LIMIT)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './animation-manager'
|
||||
export * from './blink'
|
||||
export * from './expression'
|
||||
export * from './gaze'
|
||||
export * from './lip-sync'
|
||||
export * from './loader'
|
||||
export * from './morph'
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { Profile } from 'wlipsync'
|
||||
|
||||
import type { VowelSlot } from '../../constants/morphs'
|
||||
import type { MorphController } from './morph'
|
||||
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
import { createWLipSyncNode } from 'wlipsync'
|
||||
|
||||
import profile from '../../assets/lip-sync-profile.json' with { type: 'json' }
|
||||
|
||||
// NOTICE:
|
||||
// Cross-package source import (no package.json dependency) to reach the shared
|
||||
// AudioContext. Root cause: the audio context is owned by `@proj-airi/stage-ui`,
|
||||
// which in turn depends on this package, so adding it as a dependency would
|
||||
// create a cycle. The VRM renderer reaches the same store the same way.
|
||||
// Source: packages/stage-ui-three/src/composables/vrm/lip-sync.ts.
|
||||
// Removal condition: the AudioContext provider moves to a dependency-free
|
||||
// shared package both renderers can import.
|
||||
import { useAudioContext } from '../../../../stage-ui/src/stores/audio'
|
||||
|
||||
/** wLipSync emits 6 visemes; we fold the sibilant `S` into `I`. */
|
||||
const RAW_KEYS = ['A', 'E', 'I', 'O', 'U', 'S'] as const
|
||||
type LipKey = 'A' | 'E' | 'I' | 'O' | 'U'
|
||||
const LIP_KEYS: LipKey[] = ['A', 'E', 'I', 'O', 'U']
|
||||
|
||||
/** Maps wLipSync visemes onto the MMD vowel morph slots (あいうえお). */
|
||||
const LIP_TO_SLOT: Record<LipKey, VowelSlot> = {
|
||||
A: 'vowelA',
|
||||
E: 'vowelE',
|
||||
I: 'vowelI',
|
||||
O: 'vowelO',
|
||||
U: 'vowelU',
|
||||
}
|
||||
const RAW_TO_LIP: Record<typeof RAW_KEYS[number], LipKey> = {
|
||||
A: 'A',
|
||||
E: 'E',
|
||||
I: 'I',
|
||||
O: 'O',
|
||||
U: 'U',
|
||||
S: 'I',
|
||||
}
|
||||
|
||||
const ATTACK = 50 // approach speed toward the next mouth shape
|
||||
const RELEASE = 30 // decay speed when a shape ends
|
||||
const CAP = 0.7 // max morph weight, mirrors the VRM tuning
|
||||
const SILENCE_VOL = 0.04
|
||||
const SILENCE_GAIN = 0.05
|
||||
const IDLE_MS = 160
|
||||
|
||||
/**
|
||||
* Audio-driven mouth animation for MMD models.
|
||||
*
|
||||
* Reuses the exact wLipSync profile and winner/runner blending strategy as
|
||||
* the VRM renderer (only the two strongest visemes are mixed, so the wide
|
||||
* "A" shape does not dominate), but writes the result to MMD vowel morphs
|
||||
* through a {@link MorphController} instead of VRM expressions.
|
||||
*
|
||||
* Returns an `update(delta)` to call once per frame, after the animation
|
||||
* helper has run, so lip-sync wins over any VMD mouth keyframes.
|
||||
*/
|
||||
export function useMMDLipSync(audioNode: Ref<AudioBufferSourceNode | undefined>) {
|
||||
const { audioContext } = useAudioContext()
|
||||
const { state: lipSyncNode, isReady } = useAsyncState(createWLipSyncNode(audioContext, profile as Profile), undefined)
|
||||
|
||||
const smoothState: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
|
||||
let lastActiveAt = 0
|
||||
|
||||
watch([isReady, audioNode], ([ready, newAudioNode], [, oldAudioNode]) => {
|
||||
if (oldAudioNode && oldAudioNode !== newAudioNode) {
|
||||
try {
|
||||
oldAudioNode.disconnect()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
if (!ready || !newAudioNode || !lipSyncNode.value)
|
||||
return
|
||||
try {
|
||||
newAudioNode.connect(lipSyncNode.value)
|
||||
}
|
||||
catch {}
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => audioNode.value?.disconnect())
|
||||
|
||||
function update(morphs: MorphController | undefined, delta = 0.016) {
|
||||
const node = lipSyncNode.value
|
||||
if (!morphs || !node)
|
||||
return
|
||||
|
||||
const vol = node.volume ?? 0
|
||||
const amp = Math.min(vol * 0.9, 1) ** 0.7
|
||||
|
||||
// Project the 6 raw visemes down to 5 vowels, scaled by amplitude.
|
||||
const projected: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
|
||||
for (const raw of RAW_KEYS) {
|
||||
const lip = RAW_TO_LIP[raw]
|
||||
const rawVal = node.weights[raw] ?? 0
|
||||
projected[lip] = Math.max(projected[lip], rawVal * amp)
|
||||
}
|
||||
|
||||
// Only blend the two strongest vowels. Mixing all five biases toward the
|
||||
// wide "A" shape because it has the largest deformation.
|
||||
let winner: LipKey = 'I'
|
||||
let runner: LipKey = 'E'
|
||||
let winnerVal = -Infinity
|
||||
let runnerVal = -Infinity
|
||||
for (const key of LIP_KEYS) {
|
||||
const val = projected[key]
|
||||
if (val > winnerVal) {
|
||||
runnerVal = winnerVal
|
||||
runner = winner
|
||||
winnerVal = val
|
||||
winner = key
|
||||
}
|
||||
else if (val > runnerVal) {
|
||||
runnerVal = val
|
||||
runner = key
|
||||
}
|
||||
}
|
||||
|
||||
// Treat low energy / brief gaps as silence so the mouth fully closes.
|
||||
const now = performance.now()
|
||||
let silent = amp < SILENCE_VOL || winnerVal < SILENCE_GAIN
|
||||
if (!silent)
|
||||
lastActiveAt = now
|
||||
if (now - lastActiveAt > IDLE_MS)
|
||||
silent = true
|
||||
|
||||
const target: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
|
||||
if (!silent) {
|
||||
target[winner] = Math.min(CAP, winnerVal)
|
||||
target[runner] = Math.min(CAP * 0.5, runnerVal * 0.6)
|
||||
}
|
||||
|
||||
for (const key of LIP_KEYS) {
|
||||
const from = smoothState[key]
|
||||
const to = target[key]
|
||||
const rate = 1 - Math.exp(-(to > from ? ATTACK : RELEASE) * delta)
|
||||
smoothState[key] = from + (to - from) * rate
|
||||
const weight = (smoothState[key] <= 0.01 ? 0 : smoothState[key]) * 0.7
|
||||
morphs.set(LIP_TO_SLOT[key], weight)
|
||||
}
|
||||
}
|
||||
|
||||
return { update }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { AnimationClip, SkinnedMesh } from 'three'
|
||||
|
||||
import { LoadingManager } from 'three'
|
||||
import { MMDLoader } from 'three-stdlib'
|
||||
|
||||
/** Maps in-archive relative asset paths to blob URLs for ZIP-loaded models. */
|
||||
export type UrlModifier = (url: string) => string
|
||||
|
||||
export interface MMDLoaderContext {
|
||||
loader: MMDLoader
|
||||
manager: LoadingManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an {@link MMDLoader} backed by a dedicated {@link LoadingManager}.
|
||||
*
|
||||
* MMD models reference their textures (and toon ramps) by relative paths
|
||||
* baked into the PMX/PMD binary. For ZIP imports those files live behind blob
|
||||
* URLs, so we install a URL modifier on the manager: the loader asks for
|
||||
* `tex/face.png`, the modifier rewrites it to the matching `blob:` URL. For
|
||||
* plain URL/preset models the modifier passes paths through unchanged.
|
||||
*
|
||||
* A fresh manager per load keeps URL-rewrite tables isolated between models.
|
||||
*/
|
||||
export function createMMDLoaderContext(urlModifier?: UrlModifier): MMDLoaderContext {
|
||||
const manager = new LoadingManager()
|
||||
if (urlModifier) {
|
||||
manager.setURLModifier((url) => {
|
||||
// NOTICE:
|
||||
// MMDLoader resolves textures by prepending the model URL's base, so for
|
||||
// ZIP imports the texture requests arrive blob-prefixed
|
||||
// (e.g. "blob:http://host/uuid/tex/face.png"). We must still run those
|
||||
// through the resolver — an earlier `blob:` short-circuit here silently
|
||||
// broke all ZIP textures. Only data: URIs (embedded toon textures) are
|
||||
// passed through untouched. The resolver falls back to the original URL
|
||||
// for the model file and any unmatched path, so this is safe.
|
||||
if (url.startsWith('data:'))
|
||||
return url
|
||||
return urlModifier(url)
|
||||
})
|
||||
}
|
||||
|
||||
return { loader: new MMDLoader(manager), manager }
|
||||
}
|
||||
|
||||
/** Loads a PMX/PMD model URL into a {@link SkinnedMesh}. */
|
||||
export function loadMMDMesh(
|
||||
loader: MMDLoader,
|
||||
url: string,
|
||||
onProgress?: (event: ProgressEvent) => void,
|
||||
): Promise<SkinnedMesh> {
|
||||
return new Promise((resolve, reject) => {
|
||||
loader.load(url, resolve, onProgress, reject)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a VMD motion file and binds it to `mesh`, producing an
|
||||
* {@link AnimationClip} ready for the mesh's `AnimationMixer`.
|
||||
*
|
||||
* `loadAnimation` may hand back either a clip or (for camera motions) a mesh;
|
||||
* AIRI only consumes model motions, so a non-clip result is rejected.
|
||||
*/
|
||||
export function loadMMDAnimationClip(
|
||||
loader: MMDLoader,
|
||||
url: string,
|
||||
mesh: SkinnedMesh,
|
||||
onProgress?: (event: ProgressEvent) => void,
|
||||
): Promise<AnimationClip> {
|
||||
return new Promise((resolve, reject) => {
|
||||
loader.loadAnimation(
|
||||
url,
|
||||
mesh,
|
||||
(result) => {
|
||||
// A bound model motion resolves to an AnimationClip (has `.tracks`).
|
||||
if (result && 'tracks' in result)
|
||||
resolve(result as AnimationClip)
|
||||
else
|
||||
reject(new Error('Loaded VMD did not produce a model animation clip'))
|
||||
},
|
||||
onProgress,
|
||||
reject,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { SkinnedMesh } from 'three'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createMorphController } from './morph'
|
||||
|
||||
// Minimal SkinnedMesh double: createMorphController only reads
|
||||
// morphTargetDictionary and morphTargetInfluences.
|
||||
function fakeMesh(names: string[]): SkinnedMesh {
|
||||
const dictionary: Record<string, number> = {}
|
||||
names.forEach((name, index) => {
|
||||
dictionary[name] = index
|
||||
})
|
||||
const mesh = {
|
||||
morphTargetDictionary: dictionary,
|
||||
morphTargetInfluences: Array.from({ length: names.length }).fill(0),
|
||||
}
|
||||
return mesh as unknown as SkinnedMesh
|
||||
}
|
||||
|
||||
describe('createMorphController', () => {
|
||||
it('resolves standard Japanese vowel and blink morphs', () => {
|
||||
const mesh = fakeMesh(['あ', 'い', 'う', 'え', 'お', 'まばたき'])
|
||||
const morphs = createMorphController(mesh)
|
||||
|
||||
expect(morphs.resolvedSlots).toContain('vowelA')
|
||||
expect(morphs.resolvedSlots).toContain('vowelI')
|
||||
expect(morphs.resolvedSlots).toContain('blink')
|
||||
})
|
||||
|
||||
it('falls back to English morph names when Japanese are absent', () => {
|
||||
const mesh = fakeMesh(['A', 'E', 'I', 'O', 'U', 'Blink'])
|
||||
const morphs = createMorphController(mesh)
|
||||
|
||||
morphs.set('vowelA', 0.5)
|
||||
expect(mesh.morphTargetInfluences![0]).toBe(0.5)
|
||||
expect(morphs.get('vowelA')).toBe(0.5)
|
||||
})
|
||||
|
||||
it('clamps weights to the [0, 1] range', () => {
|
||||
const mesh = fakeMesh(['あ'])
|
||||
const morphs = createMorphController(mesh)
|
||||
|
||||
morphs.set('vowelA', 5)
|
||||
expect(morphs.get('vowelA')).toBe(1)
|
||||
morphs.set('vowelA', -3)
|
||||
expect(morphs.get('vowelA')).toBe(0)
|
||||
})
|
||||
|
||||
it('honors an explicit override over the candidate list', () => {
|
||||
const mesh = fakeMesh(['あ', 'mouth_open'])
|
||||
const morphs = createMorphController(mesh, { vowelA: 'mouth_open' })
|
||||
|
||||
morphs.set('vowelA', 1)
|
||||
expect(mesh.morphTargetInfluences![1]).toBe(1)
|
||||
expect(mesh.morphTargetInfluences![0]).toBe(0)
|
||||
})
|
||||
|
||||
it('rebinds a slot when override() is called at runtime', () => {
|
||||
const mesh = fakeMesh(['あ', 'custom_a'])
|
||||
const morphs = createMorphController(mesh)
|
||||
|
||||
morphs.set('vowelA', 1)
|
||||
expect(mesh.morphTargetInfluences![0]).toBe(1)
|
||||
|
||||
morphs.override('vowelA', 'custom_a')
|
||||
morphs.set('vowelA', 0.4)
|
||||
expect(mesh.morphTargetInfluences![1]).toBe(0.4)
|
||||
})
|
||||
|
||||
it('only zeroes managed slots on resetManaged()', () => {
|
||||
const mesh = fakeMesh(['あ', 'unmanaged'])
|
||||
const morphs = createMorphController(mesh)
|
||||
mesh.morphTargetInfluences![1] = 0.9 // not a managed slot
|
||||
|
||||
morphs.set('vowelA', 0.7)
|
||||
morphs.resetManaged()
|
||||
|
||||
expect(morphs.get('vowelA')).toBe(0)
|
||||
expect(mesh.morphTargetInfluences![1]).toBe(0.9)
|
||||
})
|
||||
|
||||
it('ignores unresolved slots without throwing', () => {
|
||||
const mesh = fakeMesh(['somethingElse'])
|
||||
const morphs = createMorphController(mesh)
|
||||
|
||||
expect(() => morphs.set('vowelA', 1)).not.toThrow()
|
||||
expect(morphs.get('vowelA')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { SkinnedMesh } from 'three'
|
||||
|
||||
import type { MorphSlot } from '../../constants/morphs'
|
||||
|
||||
import { MORPH_CANDIDATES } from '../../constants/morphs'
|
||||
|
||||
/**
|
||||
* Resolves a logical {@link MorphSlot} to the first matching morph name that
|
||||
* actually exists on the model.
|
||||
*
|
||||
* Before:
|
||||
* - slot "vowelA" with candidates ["あ", "a", "A"]
|
||||
*
|
||||
* After (model that ships English morphs):
|
||||
* - "A"
|
||||
*/
|
||||
function resolveMorphName(slot: MorphSlot, available: Set<string>, overrides: Partial<Record<MorphSlot, string>>): string | undefined {
|
||||
const override = overrides[slot]
|
||||
if (override && available.has(override))
|
||||
return override
|
||||
|
||||
for (const candidate of MORPH_CANDIDATES[slot]) {
|
||||
if (available.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export interface MorphController {
|
||||
/** Logical slots that resolved to a real morph on this model. */
|
||||
readonly resolvedSlots: MorphSlot[]
|
||||
/** Every morph name exposed by the model, for settings UIs. */
|
||||
readonly availableMorphs: string[]
|
||||
/** Sets a logical slot's weight, clamped to [0, 1]. No-op if unresolved. */
|
||||
set: (slot: MorphSlot, weight: number) => void
|
||||
/** Reads back the current weight of a logical slot. */
|
||||
get: (slot: MorphSlot) => number
|
||||
/** Forces a slot to bind to an explicit morph name (settings override). */
|
||||
override: (slot: MorphSlot, morphName: string) => void
|
||||
/** Zeroes every slot this controller manages (leaves VMD morphs intact). */
|
||||
resetManaged: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a controller over an MMD mesh's vertex morphs.
|
||||
*
|
||||
* MMD exposes morphs through `SkinnedMesh.morphTargetDictionary` (name →
|
||||
* index) and `morphTargetInfluences` (index → weight). This controller hides
|
||||
* the index bookkeeping and the per-model name resolution so the expression,
|
||||
* blink, and lip-sync composables can speak in logical slots.
|
||||
*
|
||||
* Managed weights must be written after `MMDAnimationHelper.update()` each
|
||||
* frame: a VMD clip can also key morph influences, and we want AIRI's
|
||||
* lip-sync/expression to win for the slots it owns.
|
||||
*/
|
||||
export function createMorphController(
|
||||
mesh: SkinnedMesh,
|
||||
overrides: Partial<Record<MorphSlot, string>> = {},
|
||||
): MorphController {
|
||||
const dictionary = mesh.morphTargetDictionary ?? {}
|
||||
const available = new Set(Object.keys(dictionary))
|
||||
|
||||
const slotIndex = new Map<MorphSlot, number>()
|
||||
const slotOverrides: Partial<Record<MorphSlot, string>> = { ...overrides }
|
||||
|
||||
function rebind() {
|
||||
slotIndex.clear()
|
||||
for (const slot of Object.keys(MORPH_CANDIDATES) as MorphSlot[]) {
|
||||
const name = resolveMorphName(slot, available, slotOverrides)
|
||||
if (name !== undefined && dictionary[name] !== undefined)
|
||||
slotIndex.set(slot, dictionary[name])
|
||||
}
|
||||
}
|
||||
rebind()
|
||||
|
||||
return {
|
||||
get resolvedSlots() {
|
||||
return Array.from(slotIndex.keys())
|
||||
},
|
||||
get availableMorphs() {
|
||||
return Array.from(available)
|
||||
},
|
||||
set(slot, weight) {
|
||||
const index = slotIndex.get(slot)
|
||||
// Read morphTargetInfluences live: caching it risks writing to a stale
|
||||
// or detached array if three (re)assigns it during updateMorphTargets.
|
||||
const influences = mesh.morphTargetInfluences
|
||||
if (index === undefined || !influences)
|
||||
return
|
||||
influences[index] = weight < 0 ? 0 : weight > 1 ? 1 : weight
|
||||
},
|
||||
get(slot) {
|
||||
const index = slotIndex.get(slot)
|
||||
const influences = mesh.morphTargetInfluences
|
||||
return index === undefined || !influences ? 0 : influences[index] ?? 0
|
||||
},
|
||||
override(slot, morphName) {
|
||||
slotOverrides[slot] = morphName
|
||||
rebind()
|
||||
},
|
||||
resetManaged() {
|
||||
const influences = mesh.morphTargetInfluences
|
||||
if (!influences)
|
||||
return
|
||||
for (const index of slotIndex.values())
|
||||
influences[index] = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Emotion } from './emotions'
|
||||
|
||||
/**
|
||||
* Optional emotion → motion-name mapping for gesture playback.
|
||||
*
|
||||
* Expressions (morphs) are always applied for an emotion; on top of that, if
|
||||
* the loaded model has a registered VMD motion under one of these names, the
|
||||
* scene also plays it as a one-shot gesture. Models rarely ship motions named
|
||||
* exactly like this, so the animation manager treats a missing name as a
|
||||
* no-op and the user can remap names from the settings panel.
|
||||
*/
|
||||
export const EMOTION_ACTION_NAME: Record<Emotion, string> = {
|
||||
[Emotion.Happy]: 'happy',
|
||||
[Emotion.Sad]: 'sad',
|
||||
[Emotion.Angry]: 'angry',
|
||||
[Emotion.Think]: 'think',
|
||||
[Emotion.Surprise]: 'surprise',
|
||||
[Emotion.Awkward]: 'awkward',
|
||||
[Emotion.Question]: 'question',
|
||||
[Emotion.Curious]: 'curious',
|
||||
[Emotion.Neutral]: 'idle',
|
||||
}
|
||||
|
||||
/** Conventional name AIRI uses for the persistent looping idle motion. */
|
||||
export const IDLE_ACTION_NAME = 'idle'
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* AIRI emotion vocabulary shared across renderers.
|
||||
*
|
||||
* Kept identical to the Live2D/Spine enums so the act-event bus in
|
||||
* `Stage.vue` can drive any renderer with the same emotion names.
|
||||
*/
|
||||
export enum Emotion {
|
||||
Happy = 'happy',
|
||||
Sad = 'sad',
|
||||
Angry = 'angry',
|
||||
Think = 'think',
|
||||
Surprise = 'surprised',
|
||||
Awkward = 'awkward',
|
||||
Question = 'question',
|
||||
Curious = 'curious',
|
||||
Neutral = 'neutral',
|
||||
}
|
||||
|
||||
export const EMOTION_VALUES = Object.values(Emotion)
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { EMOTION_ACTION_NAME } from './actions'
|
||||
import { EMOTION_VALUES } from './emotions'
|
||||
import { EMOTION_MORPHS, MORPH_CANDIDATES, VOWEL_SLOTS } from './morphs'
|
||||
|
||||
describe('mmd emotion/morph constants', () => {
|
||||
it('defines a morph state for every emotion', () => {
|
||||
for (const emotion of EMOTION_VALUES)
|
||||
expect(EMOTION_MORPHS[emotion]).toBeDefined()
|
||||
})
|
||||
|
||||
it('defines an action name for every emotion', () => {
|
||||
for (const emotion of EMOTION_VALUES)
|
||||
expect(typeof EMOTION_ACTION_NAME[emotion]).toBe('string')
|
||||
})
|
||||
|
||||
it('keeps emotion morph influence weights within [0, 1]', () => {
|
||||
for (const emotion of EMOTION_VALUES) {
|
||||
for (const influence of EMOTION_MORPHS[emotion].influences) {
|
||||
expect(influence.value).toBeGreaterThanOrEqual(0)
|
||||
expect(influence.value).toBeLessThanOrEqual(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('provides candidate names for every vowel slot', () => {
|
||||
for (const slot of VOWEL_SLOTS) {
|
||||
expect(MORPH_CANDIDATES[slot].length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('lists the canonical あいうえお as the first vowel candidates', () => {
|
||||
expect(MORPH_CANDIDATES.vowelA[0]).toBe('あ')
|
||||
expect(MORPH_CANDIDATES.vowelI[0]).toBe('い')
|
||||
expect(MORPH_CANDIDATES.vowelU[0]).toBe('う')
|
||||
expect(MORPH_CANDIDATES.vowelE[0]).toBe('え')
|
||||
expect(MORPH_CANDIDATES.vowelO[0]).toBe('お')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Emotion } from './emotions'
|
||||
|
||||
/**
|
||||
* Logical morph slots AIRI drives on an MMD model.
|
||||
*
|
||||
* MMD (PMX/PMD) models expose vertex morphs by Japanese (or, for some
|
||||
* Western models, English) names. The exact names differ per model, so we
|
||||
* never hard-code a single string: each logical slot maps to an ordered list
|
||||
* of candidate morph names, and the runtime resolver (see
|
||||
* `resolveMorphName`) picks the first candidate that exists on the loaded
|
||||
* skeleton. Models can still override the resolved name from the settings
|
||||
* panel.
|
||||
*/
|
||||
export type MorphSlot
|
||||
= | 'vowelA'
|
||||
| 'vowelI'
|
||||
| 'vowelU'
|
||||
| 'vowelE'
|
||||
| 'vowelO'
|
||||
| 'blink'
|
||||
| 'blinkLeft'
|
||||
| 'blinkRight'
|
||||
| 'smile'
|
||||
| 'anger'
|
||||
| 'sad'
|
||||
| 'surprise'
|
||||
| 'troubled'
|
||||
| 'serious'
|
||||
|
||||
/**
|
||||
* Ordered candidate morph names per logical slot.
|
||||
*
|
||||
* Order matters: the first existing candidate wins. Japanese names come
|
||||
* first because the overwhelming majority of MMD models ship them; English
|
||||
* fallbacks cover the minority of Western/Blender-exported models.
|
||||
*/
|
||||
export const MORPH_CANDIDATES: Record<MorphSlot, readonly string[]> = {
|
||||
// Mouth vowels — the canonical MMD lip set (あいうえお).
|
||||
vowelA: ['あ', 'a', 'A'],
|
||||
vowelI: ['い', 'i', 'I'],
|
||||
vowelU: ['う', 'u', 'U'],
|
||||
vowelE: ['え', 'e', 'E'],
|
||||
vowelO: ['お', 'o', 'O'],
|
||||
|
||||
// Eyes.
|
||||
blink: ['まばたき', 'blink', 'Blink'],
|
||||
blinkLeft: ['ウィンク', 'ウインク', 'wink', 'Wink'],
|
||||
blinkRight: ['ウィンク右', 'ウインク右', 'ウィンク2', 'wink_r', 'Wink_R'],
|
||||
|
||||
// Expression morphs. Multiple common spellings per emotion are listed
|
||||
// because model authors are inconsistent (にこり vs 笑い for "smile", etc.).
|
||||
smile: ['笑い', 'にこり', 'わらい', 'smile', 'Smile'],
|
||||
anger: ['怒り', 'いかり', 'anger', 'Anger'],
|
||||
sad: ['悲しい', '悲しむ', 'sad', 'Sad'],
|
||||
surprise: ['驚き', 'びっくり', 'surprise', 'Surprise'],
|
||||
troubled: ['困る', 'こまる', 'troubled', 'Troubled'],
|
||||
serious: ['真面目', 'じと目', 'serious', 'Serious'],
|
||||
}
|
||||
|
||||
/** Vowel slots in the order used by the lip-sync mixer. */
|
||||
export const VOWEL_SLOTS = ['vowelA', 'vowelI', 'vowelU', 'vowelE', 'vowelO'] as const
|
||||
export type VowelSlot = typeof VOWEL_SLOTS[number]
|
||||
|
||||
/** A single weighted morph target produced by an emotion definition. */
|
||||
export interface MorphInfluence {
|
||||
slot: MorphSlot
|
||||
/** Full-weight target in [0, 1]; actual applied weight scales by intensity. */
|
||||
value: number
|
||||
}
|
||||
|
||||
/** Per-emotion morph targets plus the cross-fade time used to reach them. */
|
||||
export interface EmotionMorphState {
|
||||
influences: MorphInfluence[]
|
||||
/** Cross-fade duration in seconds. */
|
||||
blendDuration: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps AIRI emotions to MMD morph targets.
|
||||
*
|
||||
* Primary expression weights stay at 0.7–0.8 (not 1.0) to avoid the
|
||||
* over-expressive look reported for VRM in #590; the secondary mouth morph
|
||||
* adds a small amount of shape so the face does not read as flat.
|
||||
*/
|
||||
export const EMOTION_MORPHS: Record<Emotion, EmotionMorphState> = {
|
||||
[Emotion.Happy]: {
|
||||
influences: [{ slot: 'smile', value: 0.8 }, { slot: 'vowelA', value: 0.2 }],
|
||||
blendDuration: 0.4,
|
||||
},
|
||||
[Emotion.Sad]: {
|
||||
influences: [{ slot: 'sad', value: 0.75 }, { slot: 'troubled', value: 0.3 }],
|
||||
blendDuration: 0.4,
|
||||
},
|
||||
[Emotion.Angry]: {
|
||||
influences: [{ slot: 'anger', value: 0.8 }],
|
||||
blendDuration: 0.3,
|
||||
},
|
||||
[Emotion.Surprise]: {
|
||||
influences: [{ slot: 'surprise', value: 0.8 }, { slot: 'vowelO', value: 0.4 }],
|
||||
blendDuration: 0.15,
|
||||
},
|
||||
[Emotion.Think]: {
|
||||
influences: [{ slot: 'troubled', value: 0.5 }, { slot: 'serious', value: 0.4 }],
|
||||
blendDuration: 0.5,
|
||||
},
|
||||
[Emotion.Awkward]: {
|
||||
influences: [{ slot: 'troubled', value: 0.6 }, { slot: 'smile', value: 0.2 }],
|
||||
blendDuration: 0.4,
|
||||
},
|
||||
[Emotion.Question]: {
|
||||
influences: [{ slot: 'troubled', value: 0.4 }, { slot: 'surprise', value: 0.2 }],
|
||||
blendDuration: 0.4,
|
||||
},
|
||||
[Emotion.Curious]: {
|
||||
influences: [{ slot: 'surprise', value: 0.35 }, { slot: 'smile', value: 0.25 }],
|
||||
blendDuration: 0.4,
|
||||
},
|
||||
[Emotion.Neutral]: {
|
||||
influences: [],
|
||||
blendDuration: 0.6,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard MMD humanoid bone names AIRI manipulates for gaze and head aim.
|
||||
*
|
||||
* These are the de-facto standard Japanese bone names ("semi-standard bone"
|
||||
* convention) shared by the vast majority of MMD models.
|
||||
*/
|
||||
export const MMD_BONE = {
|
||||
bothEyes: '両目',
|
||||
leftEye: '左目',
|
||||
rightEye: '右目',
|
||||
head: '頭',
|
||||
neck: '首',
|
||||
} as const
|
||||
@@ -0,0 +1,11 @@
|
||||
export { default as MMDScene } from './components/scenes/MMD.vue'
|
||||
export * from './composables/mmd'
|
||||
export * from './constants/actions'
|
||||
export * from './constants/emotions'
|
||||
export * from './constants/morphs'
|
||||
export * from './stores'
|
||||
export * from './utils/ammo'
|
||||
export * from './utils/mmd-loader'
|
||||
export * from './utils/mmd-preview'
|
||||
export * from './utils/mmd-validator'
|
||||
export * from './utils/mmd-zip-loader'
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './mmd'
|
||||
export * from './view-control'
|
||||
@@ -0,0 +1,328 @@
|
||||
import type { Emotion } from '../constants/emotions'
|
||||
import type { MorphSlot } from '../constants/morphs'
|
||||
|
||||
import localforage from 'localforage'
|
||||
|
||||
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
|
||||
import { useBroadcastChannel } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { EMOTION_ACTION_NAME } from '../constants/actions'
|
||||
import { supportedControl, useMMDViewControl } from './view-control'
|
||||
|
||||
type BroadcastChannelEvents
|
||||
= | BroadcastChannelEventShouldUpdateView
|
||||
| BroadcastChannelEventPlayOneShot
|
||||
|
||||
interface BroadcastChannelEventShouldUpdateView {
|
||||
type: 'mmd-should-update-view'
|
||||
}
|
||||
|
||||
interface BroadcastChannelEventPlayOneShot {
|
||||
type: 'mmd-play-one-shot'
|
||||
request: MMDOneShotAction
|
||||
}
|
||||
|
||||
/**
|
||||
* A VMD motion imported for use across windows.
|
||||
*
|
||||
* Holds only an id + display name; the VMD file itself lives in IndexedDB
|
||||
* (keyed by `id`) so the list can sync through localStorage and each window
|
||||
* can materialize its own object URL. Blob URLs are window-scoped and cannot
|
||||
* be shared, which is why the URL is never persisted here.
|
||||
*/
|
||||
export interface MMDMotionDescriptor {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** IndexedDB record for a persisted VMD file. */
|
||||
interface PersistedMMDMotion {
|
||||
id: string
|
||||
name: string
|
||||
file: File
|
||||
}
|
||||
|
||||
/** A material ("part") of the loaded model, for the materials settings UI. */
|
||||
export interface MMDMaterialDescriptor {
|
||||
/** Raw material name; the key used for opacity overrides. */
|
||||
name: string
|
||||
/** Human-friendly label (falls back to `Material N` for unnamed parts). */
|
||||
label: string
|
||||
/** Order index within the model. */
|
||||
index: number
|
||||
}
|
||||
|
||||
const MOTION_STORAGE_PREFIX = 'mmd-motion-'
|
||||
|
||||
/** Eye/head tracking mode, mirroring the VRM renderer's tracking modes. */
|
||||
export type MMDGazeMode = 'camera' | 'mouse' | 'none'
|
||||
|
||||
/** Transient request to play a one-shot motion, bumped per request. */
|
||||
export interface MMDOneShotAction {
|
||||
name: string
|
||||
loop: boolean
|
||||
nonce: number
|
||||
}
|
||||
|
||||
export const useMMD = defineStore('mmd', () => {
|
||||
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({
|
||||
name: 'airi-stores-stage-ui-mmd',
|
||||
})
|
||||
const shouldUpdateViewHooks = ref(new Set<() => void>())
|
||||
|
||||
/**
|
||||
* Transient one-shot motion request (fire-and-forget; not persisted).
|
||||
*
|
||||
* Declared before the broadcast handler that writes to it. Delivered across
|
||||
* Electron windows via the BroadcastChannel below — NOT localStorage, whose
|
||||
* storage events do not fire reliably between BrowserWindows.
|
||||
*/
|
||||
const oneShotAction = ref<MMDOneShotAction>()
|
||||
|
||||
const onShouldUpdateView = (hook: () => void) => {
|
||||
shouldUpdateViewHooks.value.add(hook)
|
||||
return () => {
|
||||
shouldUpdateViewHooks.value.delete(hook)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUpdateView() {
|
||||
post({ type: 'mmd-should-update-view' })
|
||||
shouldUpdateViewHooks.value.forEach(hook => hook())
|
||||
}
|
||||
|
||||
watch(data, (event) => {
|
||||
if (event?.type === 'mmd-should-update-view')
|
||||
shouldUpdateViewHooks.value.forEach(hook => hook())
|
||||
else if (event?.type === 'mmd-play-one-shot')
|
||||
oneShotAction.value = event.request
|
||||
})
|
||||
|
||||
// === Physics & solver toggles ===
|
||||
/** Master switch for the Ammo/Bullet rigid-body simulation (hair, skirt, etc.). */
|
||||
const physicsEnabled = useLocalStorageManualReset<boolean>('settings/mmd/physics-enabled', true)
|
||||
/** CCD IK solving for limbs/standard rigs. */
|
||||
const ikEnabled = useLocalStorageManualReset<boolean>('settings/mmd/ik-enabled', true)
|
||||
/** Append-bone ("grant") propagation for derived bones. */
|
||||
const grantEnabled = useLocalStorageManualReset<boolean>('settings/mmd/grant-enabled', true)
|
||||
/**
|
||||
* Physics gravity strength (magnitude). Applied as world gravity (0, -g, 0).
|
||||
* MMD's default is 98 (9.8 × 10 for MMD's scale). Lower = floatier hair and
|
||||
* cloth, higher = heavier/droopier.
|
||||
*/
|
||||
const physicsGravity = useLocalStorageManualReset<number>('settings/mmd/physics-gravity', 98)
|
||||
|
||||
// === Gaze ===
|
||||
/**
|
||||
* Eye/head tracking mode, mirroring the VRM renderer:
|
||||
* - `mouse` — follow the cursor
|
||||
* - `camera` — look toward the camera (forward)
|
||||
* - `none` — no tracking; idle saccades only
|
||||
*/
|
||||
const gazeMode = useLocalStorageManualReset<MMDGazeMode>('settings/mmd/gaze-mode', 'mouse')
|
||||
|
||||
// === Scene: camera ===
|
||||
/** Vertical field of view in degrees. */
|
||||
const cameraFov = useLocalStorageManualReset<number>('settings/mmd/camera-fov', 30)
|
||||
|
||||
// === Scene: lighting ===
|
||||
const ambientColor = useLocalStorageManualReset<string>('settings/mmd/ambient-color', '#FFFFFF')
|
||||
const ambientIntensity = useLocalStorageManualReset<number>('settings/mmd/ambient-intensity', 0.6)
|
||||
const directionalColor = useLocalStorageManualReset<string>('settings/mmd/directional-color', '#FFFBF5')
|
||||
const directionalIntensity = useLocalStorageManualReset<number>('settings/mmd/directional-intensity', 0.75)
|
||||
const directionalPosition = useLocalStorageManualReset<{ x: number, y: number, z: number }>(
|
||||
'settings/mmd/directional-position',
|
||||
() => ({ x: 1, y: 2, z: 2 }),
|
||||
)
|
||||
|
||||
// === Scene: rendering ===
|
||||
/**
|
||||
* Albedo self-illumination, 0–1. Drives `emissiveIntensity` on every
|
||||
* material so the model reads as the flat, luminous MMD/anime look. 0 = lit
|
||||
* only by scene lights, 1 = nearly self-lit.
|
||||
*/
|
||||
const albedoGlow = useLocalStorageManualReset<number>('settings/mmd/albedo-glow', 0.45)
|
||||
/** Device-pixel-ratio multiplier for render resolution. */
|
||||
const renderScale = useLocalStorageManualReset<number>('settings/mmd/render-scale', 1)
|
||||
|
||||
// === Animation ===
|
||||
/** Name of the persistent idle motion; empty means none/static. */
|
||||
const idleMotionName = useLocalStorageManualReset<string>('settings/mmd/idle-motion', '')
|
||||
/**
|
||||
* Motions imported for use across windows.
|
||||
*
|
||||
* localStorage-backed so the list syncs between the settings window and the
|
||||
* stage window (the desktop app runs them as separate renderer processes
|
||||
* with independent Pinia stores). Only id + name are stored here; the VMD
|
||||
* files live in IndexedDB.
|
||||
*/
|
||||
const availableMotions = useLocalStorageManualReset<MMDMotionDescriptor[]>('settings/mmd/motions', () => [])
|
||||
|
||||
/**
|
||||
* Persists a VMD file to IndexedDB and adds it to the synced list.
|
||||
*
|
||||
* Re-importing the same name overwrites the stored file (same id) so the
|
||||
* list stays stable.
|
||||
*/
|
||||
async function addMotion(file: File): Promise<MMDMotionDescriptor> {
|
||||
const name = file.name.replace(/\.vmd$/i, '')
|
||||
const existing = availableMotions.value.find(motion => motion.name === name)
|
||||
const id = existing?.id ?? `${MOTION_STORAGE_PREFIX}${crypto.randomUUID()}`
|
||||
|
||||
await localforage.setItem<PersistedMMDMotion>(id, { id, name, file })
|
||||
if (!existing)
|
||||
availableMotions.value = [...availableMotions.value, { id, name }]
|
||||
|
||||
return { id, name }
|
||||
}
|
||||
|
||||
/** Loads a persisted VMD file by id so a window can build its own object URL. */
|
||||
async function getMotionFile(id: string): Promise<File | undefined> {
|
||||
const persisted = await localforage.getItem<PersistedMMDMotion>(id)
|
||||
return persisted?.file
|
||||
}
|
||||
|
||||
/** Removes every persisted motion file and clears the synced list. */
|
||||
async function clearMotions(): Promise<void> {
|
||||
const motions = availableMotions.value
|
||||
availableMotions.value = []
|
||||
await Promise.all(motions.map(motion => localforage.removeItem(motion.id)))
|
||||
}
|
||||
|
||||
/** Removes a single imported motion (from the synced list and IndexedDB). */
|
||||
async function removeMotion(id: string): Promise<void> {
|
||||
const motion = availableMotions.value.find(m => m.id === id)
|
||||
availableMotions.value = availableMotions.value.filter(m => m.id !== id)
|
||||
// Drop the idle selection if it pointed at the removed motion.
|
||||
if (motion && idleMotionName.value === motion.name)
|
||||
idleMotionName.value = ''
|
||||
await localforage.removeItem(id)
|
||||
}
|
||||
/** Per-emotion gesture motion overrides; falls back to EMOTION_ACTION_NAME. */
|
||||
const emotionActionMap = useLocalStorageManualReset<Record<Emotion, string>>(
|
||||
'settings/mmd/emotion-action-map',
|
||||
() => ({ ...EMOTION_ACTION_NAME }),
|
||||
)
|
||||
|
||||
// === Morphs ===
|
||||
/** Manual morph-slot → morph-name overrides for non-standard models. */
|
||||
const morphOverrides = useLocalStorageManualReset<Partial<Record<MorphSlot, string>>>(
|
||||
'settings/mmd/morph-overrides',
|
||||
() => ({}),
|
||||
)
|
||||
/** All morph names exposed by the active model (runtime). */
|
||||
const availableMorphs = useLocalStorageManualReset<string[]>('settings/mmd/available-morphs', () => [])
|
||||
|
||||
/**
|
||||
* Materials (parts) of the active model, published by the scene so the
|
||||
* settings window can render a control per part.
|
||||
*/
|
||||
const availableMaterials = useLocalStorageManualReset<MMDMaterialDescriptor[]>(
|
||||
'settings/mmd/available-materials',
|
||||
() => [],
|
||||
)
|
||||
/**
|
||||
* Per-material opacity overrides, keyed by material name. Missing entries
|
||||
* render at full opacity. Lets the user fade or hide individual parts.
|
||||
*/
|
||||
const materialOpacity = useLocalStorageManualReset<Record<string, number>>(
|
||||
'settings/mmd/material-opacity',
|
||||
() => ({}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether an MMD mesh is currently mounted. Runtime-only: the persisted
|
||||
* descriptor lists survive reloads and cannot indicate live mount state.
|
||||
*/
|
||||
const isModelLoaded = ref(false)
|
||||
|
||||
/**
|
||||
* Queues a one-shot motion. Applies it in this window and broadcasts it to
|
||||
* the others. The bumped `nonce` makes repeat requests for the same motion
|
||||
* re-trigger the scene's watcher.
|
||||
*
|
||||
* Uses the BroadcastChannel rather than localStorage because this is a
|
||||
* transient trigger that must reach the stage window's animation manager,
|
||||
* and localStorage storage events do not fire reliably across Electron
|
||||
* BrowserWindows (the live stage and settings run as separate processes).
|
||||
*/
|
||||
function playOneShotAction(name: string, loop = false) {
|
||||
const request: MMDOneShotAction = { name, loop, nonce: (oneShotAction.value?.nonce ?? 0) + 1 }
|
||||
oneShotAction.value = request
|
||||
post({ type: 'mmd-play-one-shot', request })
|
||||
}
|
||||
|
||||
const { position, scale, rotationY, reset: resetViewControl } = useMMDViewControl()
|
||||
|
||||
function resetState() {
|
||||
supportedControl.forEach(c => resetViewControl(c))
|
||||
physicsEnabled.reset()
|
||||
ikEnabled.reset()
|
||||
grantEnabled.reset()
|
||||
physicsGravity.reset()
|
||||
gazeMode.reset()
|
||||
cameraFov.reset()
|
||||
ambientColor.reset()
|
||||
ambientIntensity.reset()
|
||||
directionalColor.reset()
|
||||
directionalIntensity.reset()
|
||||
directionalPosition.reset()
|
||||
albedoGlow.reset()
|
||||
renderScale.reset()
|
||||
idleMotionName.reset()
|
||||
void clearMotions()
|
||||
oneShotAction.value = undefined
|
||||
emotionActionMap.reset()
|
||||
morphOverrides.reset()
|
||||
availableMorphs.reset()
|
||||
availableMaterials.reset()
|
||||
materialOpacity.reset()
|
||||
shouldUpdateView()
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
scale,
|
||||
rotationY,
|
||||
|
||||
physicsEnabled,
|
||||
ikEnabled,
|
||||
grantEnabled,
|
||||
physicsGravity,
|
||||
gazeMode,
|
||||
|
||||
cameraFov,
|
||||
ambientColor,
|
||||
ambientIntensity,
|
||||
directionalColor,
|
||||
directionalIntensity,
|
||||
directionalPosition,
|
||||
albedoGlow,
|
||||
renderScale,
|
||||
|
||||
idleMotionName,
|
||||
availableMotions,
|
||||
addMotion,
|
||||
getMotionFile,
|
||||
clearMotions,
|
||||
removeMotion,
|
||||
emotionActionMap,
|
||||
|
||||
morphOverrides,
|
||||
availableMorphs,
|
||||
availableMaterials,
|
||||
materialOpacity,
|
||||
|
||||
isModelLoaded,
|
||||
oneShotAction,
|
||||
playOneShotAction,
|
||||
|
||||
onShouldUpdateView,
|
||||
shouldUpdateView,
|
||||
resetState,
|
||||
}
|
||||
})
|
||||
|
||||
export { useMMDViewControl }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const supportedControl = ['x', 'y', 'scale', 'rotationY'] as const
|
||||
type SupportedControl = typeof supportedControl[number]
|
||||
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
|
||||
|
||||
const viewControlsEnabled = ref(false)
|
||||
const viewControlMode = ref<SupportedControl>('scale')
|
||||
|
||||
/** Model world offset from origin, in scene units. */
|
||||
const position = useLocalStorage<{ x: number, y: number }>('settings/mmd/position', { x: 0, y: 0 })
|
||||
/** Uniform model scale. MMD models are authored large, so default is small. */
|
||||
const scale = useLocalStorage('settings/mmd/scale', 0.1)
|
||||
/** Yaw applied to the model group, in radians. */
|
||||
const rotationY = useLocalStorage('settings/mmd/rotationY', 0)
|
||||
|
||||
const formatUnits = (val: number) => val.toFixed(2)
|
||||
const formatToPercent = (val: number) => `${(val * 100).toFixed(0)}%`
|
||||
const formatDegrees = (val: number) => `${(val * 180 / Math.PI).toFixed(0)}°`
|
||||
|
||||
export const controlConfig: Record<SupportedControl, ControlConfig> = {
|
||||
x: { min: -20, max: 20, step: 0.1, default: 0, format: formatUnits },
|
||||
y: { min: -20, max: 20, step: 0.1, default: 0, format: formatUnits },
|
||||
scale: { min: 0.01, max: 1, step: 0.01, default: 0.1, format: formatToPercent },
|
||||
rotationY: { min: -Math.PI, max: Math.PI, step: 0.01, default: 0, format: formatDegrees },
|
||||
}
|
||||
|
||||
export function useMMDViewControl() {
|
||||
function reset(key: SupportedControl) {
|
||||
switch (key) {
|
||||
case 'x':
|
||||
position.value.x = controlConfig.x.default
|
||||
break
|
||||
case 'y':
|
||||
position.value.y = controlConfig.y.default
|
||||
break
|
||||
case 'scale':
|
||||
scale.value = controlConfig.scale.default
|
||||
break
|
||||
case 'rotationY':
|
||||
rotationY.value = controlConfig.rotationY.default
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
scale,
|
||||
rotationY,
|
||||
reset,
|
||||
viewControlsEnabled,
|
||||
viewControlMode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type Ammo from 'ammojs-typed'
|
||||
|
||||
/**
|
||||
* Lazily initializes the Ammo.js (Bullet) physics runtime and exposes it as
|
||||
* the global `Ammo` that three-stdlib's `MMDPhysics` expects.
|
||||
*
|
||||
* Why a global: `MMDPhysics` (a straight port of three's example) reads
|
||||
* `Ammo.btVector3`, `Ammo.btRigidBody`, etc. off the global scope rather
|
||||
* than taking the runtime as a constructor argument. We therefore have to
|
||||
* publish the resolved module on `globalThis` before constructing any
|
||||
* physics world.
|
||||
*
|
||||
* Why lazy: the Ammo WASM binary plus its JS glue is large (~1 MB+). It is
|
||||
* pulled in via dynamic `import()` so neither the glue nor the WASM lands in
|
||||
* the main bundle until the user actually mounts an MMD model.
|
||||
*
|
||||
* The promise is memoized: concurrent callers and re-mounts share a single
|
||||
* WASM instantiation.
|
||||
*/
|
||||
let ammoReady: Promise<typeof Ammo> | undefined
|
||||
|
||||
interface AmmoGlobal {
|
||||
Ammo?: typeof Ammo
|
||||
}
|
||||
|
||||
export async function ensureAmmo(): Promise<typeof Ammo> {
|
||||
if (ammoReady)
|
||||
return ammoReady
|
||||
|
||||
ammoReady = import('ammojs-typed')
|
||||
.then(module => module.default())
|
||||
.then((lib) => {
|
||||
// NOTICE:
|
||||
// MMDPhysics resolves Bullet classes from the ambient global `Ammo`.
|
||||
// Root cause: three-stdlib/animation/MMDPhysics.js does `typeof Ammo`
|
||||
// and `new Ammo.btVector3(...)` against the global scope.
|
||||
// Source: node_modules/three-stdlib/animation/MMDPhysics.js (lines 14, 98+).
|
||||
// Removal condition: three-stdlib accepts an injected Ammo instance.
|
||||
;(globalThis as AmmoGlobal).Ammo = lib
|
||||
return lib
|
||||
})
|
||||
|
||||
return ammoReady
|
||||
}
|
||||
|
||||
/** Whether the Ammo runtime has already been published on the global scope. */
|
||||
export function isAmmoReady(): boolean {
|
||||
return Boolean((globalThis as AmmoGlobal).Ammo)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { withModelExtension } from './mmd-loader'
|
||||
|
||||
describe('withModelExtension', () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// MMDLoader.load() chooses the PMX/PMD parser from the URL file extension
|
||||
// (_extractExtension -> lastIndexOf('.')). Object/blob URLs produced by
|
||||
// URL.createObjectURL have no extension, so importing an MMD .zip failed with
|
||||
// "THREE.MMDLoader: Unknown model file extension .".
|
||||
//
|
||||
// We append the known format as a URL fragment so the extension sniff
|
||||
// succeeds; the blob URL store ignores the fragment when fetching.
|
||||
it('tags an extensionless blob URL with the known format (Issue: blob import)', () => {
|
||||
expect(withModelExtension('blob:http://host/9f1c-abc', 'pmx')).toBe('blob:http://host/9f1c-abc#airi-model.pmx')
|
||||
expect(withModelExtension('blob:http://host/9f1c-abc', 'pmd')).toBe('blob:http://host/9f1c-abc#airi-model.pmd')
|
||||
})
|
||||
|
||||
it('leaves URLs that already carry a real extension unchanged', () => {
|
||||
expect(withModelExtension('https://cdn/models/miku.pmx', 'pmx')).toBe('https://cdn/models/miku.pmx')
|
||||
expect(withModelExtension('https://cdn/models/model.PMD', 'pmd')).toBe('https://cdn/models/model.PMD')
|
||||
})
|
||||
|
||||
it('ignores query/fragment when checking for an existing extension', () => {
|
||||
expect(withModelExtension('https://cdn/miku.pmx?v=2', 'pmx')).toBe('https://cdn/miku.pmx?v=2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { Color, LoadingManager, Material, SkinnedMesh, Texture } from 'three'
|
||||
|
||||
import type { MMDLoadedAssets, MMDModelFormat } from './mmd-zip-loader'
|
||||
|
||||
import { Mesh, SRGBColorSpace } from 'three'
|
||||
|
||||
import { createMMDLoaderContext, loadMMDMesh } from '../composables/mmd/loader'
|
||||
import { loadMMDZip } from './mmd-zip-loader'
|
||||
|
||||
export interface ResolvedMMDModel {
|
||||
mesh: SkinnedMesh
|
||||
format: MMDModelFormat
|
||||
/** Present only when the source was a ZIP archive. */
|
||||
assets?: MMDLoadedAssets
|
||||
/** Revokes any blob URLs created while resolving the model. */
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export interface LoadMMDOptions {
|
||||
/**
|
||||
* Wait for the model's textures to finish loading before resolving.
|
||||
*
|
||||
* MMDLoader resolves the mesh as soon as it is parsed; textures continue
|
||||
* loading through the LoadingManager. The live scene renders continuously so
|
||||
* textures appear within a frame or two, but a one-shot offscreen render
|
||||
* (the preview) would capture an untextured/transparent frame. Enable this
|
||||
* for previews. Defaults to `false`.
|
||||
*/
|
||||
waitForTextures?: boolean
|
||||
}
|
||||
|
||||
// ZIP local-file-header magic: "PK\x03\x04".
|
||||
function isZip(buffer: ArrayBuffer): boolean {
|
||||
const head = new Uint8Array(buffer, 0, Math.min(4, buffer.byteLength))
|
||||
return head[0] === 0x50 && head[1] === 0x4B && head[2] === 0x03 && head[3] === 0x04
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the manager has no more pending loads.
|
||||
*
|
||||
* `LoadingManager.onLoad` fires when the last queued item finishes. A timeout
|
||||
* guards the case where everything is already loaded (so `onLoad` never fires)
|
||||
* or a texture stalls, so preview generation can never hang.
|
||||
*/
|
||||
function waitForManagerIdle(manager: LoadingManager, timeoutMs = 4000): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const finish = () => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
timer = setTimeout(finish, timeoutMs)
|
||||
manager.onLoad = finish
|
||||
})
|
||||
}
|
||||
|
||||
/** Material with the slots we adjust for correct MMD shading under r184. */
|
||||
type ColorMappedMaterial = Material & {
|
||||
map?: Texture | null
|
||||
emissiveMap?: Texture | null
|
||||
emissive?: Color
|
||||
emissiveIntensity?: number
|
||||
color?: Color
|
||||
}
|
||||
|
||||
/** Fraction of the albedo fed back as self-illumination for the anime glow. */
|
||||
const MMD_ALBEDO_GLOW = 0.45
|
||||
|
||||
/**
|
||||
* Corrects MMD materials for three r184 and gives them the flat, luminous
|
||||
* anime look, after load.
|
||||
*
|
||||
* Fixes:
|
||||
*
|
||||
* 1. Color space — three-stdlib's MMDLoader predates the
|
||||
* `encoding` → `colorSpace` migration and assigns color textures without a
|
||||
* color space, so under r184 they decode in linear space and read too
|
||||
* bright/desaturated. We retag color maps as sRGB; data maps
|
||||
* (normal/gradient/sphere) stay linear.
|
||||
*
|
||||
* 2. Baked ambient → albedo glow — MMDLoader maps each PMX material's ambient
|
||||
* color (環境色, a strong grey) onto `material.emissive`, which washes the
|
||||
* model out as a flat grey. MMD's actual look is a bright, slightly-shaded
|
||||
* albedo with a soft self-glow. We replace the grey emissive with the
|
||||
* material's own diffuse map (or color) at {@link MMD_ALBEDO_GLOW}
|
||||
* intensity, so each surface self-illuminates in its own color — skin glows
|
||||
* skin-colored — instead of grey.
|
||||
*/
|
||||
function fixupMMDMaterials(mesh: SkinnedMesh): void {
|
||||
mesh.traverse((object) => {
|
||||
if (!(object instanceof Mesh))
|
||||
return
|
||||
// Skinned MMD meshes report a bind-pose bounding sphere that does not
|
||||
// cover the posed/animated mesh, so they get frustum-culled when the
|
||||
// camera pulls back (e.g. the offscreen preview renders blank). Disable
|
||||
// culling, as the VRM loader does.
|
||||
object.frustumCulled = false
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material]
|
||||
for (const material of materials) {
|
||||
const mapped = material as ColorMappedMaterial
|
||||
|
||||
if (mapped.map)
|
||||
mapped.map.colorSpace = SRGBColorSpace
|
||||
|
||||
if (mapped.emissive) {
|
||||
if (mapped.map) {
|
||||
// Self-illuminate from the albedo: emissive = white × diffuse map.
|
||||
mapped.emissiveMap = mapped.map
|
||||
mapped.emissive.setScalar(1)
|
||||
}
|
||||
else if (mapped.color) {
|
||||
// No texture: glow in the flat diffuse color instead.
|
||||
mapped.emissive.copy(mapped.color)
|
||||
}
|
||||
else {
|
||||
mapped.emissive.setScalar(0)
|
||||
}
|
||||
if (typeof mapped.emissiveIntensity === 'number')
|
||||
mapped.emissiveIntensity = MMD_ALBEDO_GLOW
|
||||
}
|
||||
|
||||
if (mapped.emissiveMap)
|
||||
mapped.emissiveMap.colorSpace = SRGBColorSpace
|
||||
|
||||
material.needsUpdate = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatFromUrl(url: string): MMDModelFormat {
|
||||
return url.split(/[?#]/)[0].toLowerCase().endsWith('.pmd') ? 'pmd' : 'pmx'
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a model URL ends with a `.pmx`/`.pmd` extension that
|
||||
* `MMDLoader` can sniff.
|
||||
*
|
||||
* MMDLoader chooses the PMX vs PMD parser purely from the URL's file
|
||||
* extension (`_extractExtension` → `lastIndexOf('.')`). Object/blob URLs from
|
||||
* `URL.createObjectURL` have no extension, so the loader throws "Unknown model
|
||||
* file extension". We append the known format as a URL fragment: the blob URL
|
||||
* store ignores the fragment when fetching the blob, but the extension sniff
|
||||
* reads it. URLs that already carry a real extension are returned unchanged.
|
||||
*
|
||||
* Before:
|
||||
* - "blob:http://host/9f1c-…" (format known to be pmx)
|
||||
*
|
||||
* After:
|
||||
* - "blob:http://host/9f1c-…#airi-model.pmx"
|
||||
*/
|
||||
export function withModelExtension(url: string, format: MMDModelFormat): string {
|
||||
const path = url.split(/[?#]/)[0].toLowerCase()
|
||||
if (path.endsWith('.pmx') || path.endsWith('.pmd'))
|
||||
return url
|
||||
return `${url}#airi-model.${format}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an MMD model from an arbitrary source URL into a {@link SkinnedMesh}.
|
||||
*
|
||||
* Accepts either a packaged ZIP (the usual distribution form: model plus
|
||||
* textures) or a bare `.pmx`/`.pmd` URL. ZIP archives are unpacked to blob
|
||||
* URLs and a basename-based texture resolver is installed on the loader; raw
|
||||
* URLs are loaded directly and rely on the server's relative paths.
|
||||
*
|
||||
* The returned `dispose()` revokes any blob URLs created during the load. It
|
||||
* does not dispose the mesh's GPU resources — the scene owns that lifecycle.
|
||||
*/
|
||||
export async function loadMMDModelFromSource(src: string, options: LoadMMDOptions = {}): Promise<ResolvedMMDModel> {
|
||||
const response = await fetch(src)
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch MMD model: ${response.status} ${response.statusText}`)
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
|
||||
if (isZip(buffer)) {
|
||||
const assets = await loadMMDZip(buffer)
|
||||
const { loader, manager } = createMMDLoaderContext(assets.urlModifier)
|
||||
const mesh = await loadMMDMesh(loader, withModelExtension(assets.modelBlobUrl, assets.variant.format))
|
||||
fixupMMDMaterials(mesh)
|
||||
if (options.waitForTextures)
|
||||
await waitForManagerIdle(manager)
|
||||
return {
|
||||
mesh,
|
||||
format: assets.variant.format,
|
||||
assets,
|
||||
dispose: () => assets.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
// Raw model URL: load directly, textures resolve against the server path.
|
||||
const { loader, manager } = createMMDLoaderContext()
|
||||
const mesh = await loadMMDMesh(loader, withModelExtension(src, formatFromUrl(src)))
|
||||
fixupMMDMaterials(mesh)
|
||||
if (options.waitForTextures)
|
||||
await waitForManagerIdle(manager)
|
||||
return {
|
||||
mesh,
|
||||
format: formatFromUrl(src),
|
||||
dispose: () => {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Object3D } from 'three'
|
||||
|
||||
import {
|
||||
AmbientLight,
|
||||
Box3,
|
||||
DirectionalLight,
|
||||
Group,
|
||||
Mesh,
|
||||
PerspectiveCamera,
|
||||
Scene,
|
||||
SRGBColorSpace,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
} from 'three'
|
||||
|
||||
import { loadMMDModelFromSource } from './mmd-loader'
|
||||
|
||||
function disposeObject(root: Object3D) {
|
||||
root.traverse((obj) => {
|
||||
if (obj instanceof Mesh) {
|
||||
obj.geometry?.dispose?.()
|
||||
const material = obj.material
|
||||
if (Array.isArray(material))
|
||||
material.forEach(m => m.dispose())
|
||||
else
|
||||
material?.dispose?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an MMD model file to an offscreen canvas and returns a preview data
|
||||
* URL for the model-selector card.
|
||||
*
|
||||
* Deliberately physics-free: the thumbnail shows the model in its rest pose,
|
||||
* so there is no need to initialize Ammo or step the simulation. This keeps
|
||||
* preview generation cheap and avoids loading the WASM physics binary just to
|
||||
* import a model.
|
||||
*/
|
||||
export async function loadMMDModelPreview(file: File): Promise<string | undefined> {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 1440
|
||||
canvas.height = 2560
|
||||
|
||||
const renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, preserveDrawingBuffer: true })
|
||||
renderer.outputColorSpace = SRGBColorSpace
|
||||
renderer.setSize(canvas.width, canvas.height, false)
|
||||
renderer.setPixelRatio(1)
|
||||
|
||||
const scene = new Scene()
|
||||
const camera = new PerspectiveCamera(30, canvas.width / canvas.height, 0.1, 1000)
|
||||
scene.add(new AmbientLight(0xFFFFFF, 0.9))
|
||||
const directional = new DirectionalLight(0xFFFFFF, 1.0)
|
||||
directional.position.set(1, 2, 2)
|
||||
scene.add(directional)
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
let resolved: Awaited<ReturnType<typeof loadMMDModelFromSource>> | undefined
|
||||
let group: Group | undefined
|
||||
|
||||
try {
|
||||
resolved = await loadMMDModelFromSource(objectUrl, { waitForTextures: true })
|
||||
group = new Group()
|
||||
group.add(resolved.mesh)
|
||||
scene.add(group)
|
||||
group.updateMatrixWorld(true)
|
||||
|
||||
const box = new Box3().setFromObject(group)
|
||||
const size = box.getSize(new Vector3())
|
||||
const center = box.getCenter(new Vector3())
|
||||
// Fit to both axes for the portrait preview canvas so the model is not
|
||||
// cropped or shrunk into the distance.
|
||||
const vFov = (camera.fov * Math.PI) / 180
|
||||
const fitHeightDistance = (size.y / 2) / Math.tan(vFov / 2)
|
||||
const fitWidthDistance = (size.x / 2) / (Math.tan(vFov / 2) * camera.aspect)
|
||||
const distance = 1.15 * Math.max(fitHeightDistance, fitWidthDistance)
|
||||
camera.position.set(center.x, center.y, center.z + distance)
|
||||
camera.near = Math.max(distance / 100, 0.01)
|
||||
camera.far = distance * 100
|
||||
camera.lookAt(center)
|
||||
camera.updateProjectionMatrix()
|
||||
|
||||
renderer.render(scene, camera)
|
||||
return canvas.toDataURL()
|
||||
}
|
||||
finally {
|
||||
if (group)
|
||||
disposeObject(group)
|
||||
resolved?.dispose()
|
||||
scene.clear()
|
||||
renderer.renderLists.dispose()
|
||||
renderer.dispose()
|
||||
renderer.forceContextLoss()
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { MMDModelFormat } from './mmd-zip-loader'
|
||||
|
||||
import JSZip from 'jszip'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
export type MMDValidationStatus = 'VALID' | 'INVALID'
|
||||
|
||||
export interface MMDValidationReport {
|
||||
status: MMDValidationStatus
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
detected: {
|
||||
modelPath?: string
|
||||
format?: MMDModelFormat
|
||||
textureCount: number
|
||||
}
|
||||
}
|
||||
|
||||
const TEXTURE_RE = /\.(?:png|jpe?g|bmp|tga|gif|dds|spa|sph|webp)$/i
|
||||
|
||||
/**
|
||||
* Inspects an MMD ZIP without decoding textures into GPU memory.
|
||||
*
|
||||
* Mirrors the Live2D/Spine validator return shape so the model-selector
|
||||
* dialog can present consistent error/warning UX across formats.
|
||||
*/
|
||||
export async function validateMMDZip(file: File): Promise<MMDValidationReport> {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
const detected: MMDValidationReport['detected'] = { textureCount: 0 }
|
||||
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
const archive = await zip.loadAsync(file)
|
||||
const files = Object.keys(archive.files).filter(name => !archive.files[name].dir)
|
||||
|
||||
const pmx = files.filter(name => name.toLowerCase().endsWith('.pmx'))
|
||||
const pmd = files.filter(name => name.toLowerCase().endsWith('.pmd'))
|
||||
|
||||
if (pmx.length === 0 && pmd.length === 0) {
|
||||
errors.push('No model (`.pmx` or `.pmd`) found in the ZIP.')
|
||||
return { status: 'INVALID', errors, warnings, detected }
|
||||
}
|
||||
|
||||
if (pmx.length + pmd.length > 1)
|
||||
warnings.push(`Multiple model files detected (${pmx.length + pmd.length}). The import will use the first one.`)
|
||||
|
||||
if (pmx.length > 0) {
|
||||
detected.modelPath = pmx[0]
|
||||
detected.format = 'pmx'
|
||||
}
|
||||
else {
|
||||
detected.modelPath = pmd[0]
|
||||
detected.format = 'pmd'
|
||||
}
|
||||
|
||||
const textures = files.filter(name => TEXTURE_RE.test(name))
|
||||
detected.textureCount = textures.length
|
||||
if (textures.length === 0)
|
||||
warnings.push('No texture files detected. The model may render untextured.')
|
||||
}
|
||||
catch (err) {
|
||||
errors.push(`Failed to read ZIP: ${errorMessageFrom(err) ?? 'Unknown error'}`)
|
||||
return { status: 'INVALID', errors, warnings, detected }
|
||||
}
|
||||
|
||||
return { status: 'VALID', errors, warnings, detected }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { detectMMDVariants } from './mmd-zip-loader'
|
||||
|
||||
describe('detectMMDVariants', () => {
|
||||
it('detects a PMX model and derives its display name', () => {
|
||||
const variants = detectMMDVariants(['Miku/Miku.pmx', 'Miku/tex/face.png'])
|
||||
expect(variants).toHaveLength(1)
|
||||
expect(variants[0].modelPath).toBe('Miku/Miku.pmx')
|
||||
expect(variants[0].format).toBe('pmx')
|
||||
expect(variants[0].name).toBe('Miku')
|
||||
})
|
||||
|
||||
it('detects a PMD model', () => {
|
||||
const variants = detectMMDVariants(['model.pmd', 'toon01.bmp'])
|
||||
expect(variants).toHaveLength(1)
|
||||
expect(variants[0].format).toBe('pmd')
|
||||
})
|
||||
|
||||
it('returns every model file when an archive bundles several', () => {
|
||||
const variants = detectMMDVariants(['a/a.pmx', 'b/b.pmx', 'shared/tex.png'])
|
||||
expect(variants.map(v => v.modelPath)).toEqual(['a/a.pmx', 'b/b.pmx'])
|
||||
})
|
||||
|
||||
it('returns an empty list when no model file is present', () => {
|
||||
const variants = detectMMDVariants(['readme.txt', 'tex/face.png'])
|
||||
expect(variants).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is case-insensitive on the model extension', () => {
|
||||
const variants = detectMMDVariants(['Model.PMX'])
|
||||
expect(variants).toHaveLength(1)
|
||||
expect(variants[0].format).toBe('pmx')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { UrlModifier } from '../composables/mmd/loader'
|
||||
|
||||
import JSZip from 'jszip'
|
||||
|
||||
export type MMDModelFormat = 'pmx' | 'pmd'
|
||||
|
||||
export interface MMDModelVariant {
|
||||
/** Display name derived from the model file's basename. */
|
||||
name: string
|
||||
/** In-archive path of the `.pmx`/`.pmd` file. */
|
||||
modelPath: string
|
||||
format: MMDModelFormat
|
||||
}
|
||||
|
||||
export interface MMDLoadedAssets {
|
||||
/** Primary model variant (first discovered). */
|
||||
variant: MMDModelVariant
|
||||
/** All model files found in the archive. */
|
||||
variants: MMDModelVariant[]
|
||||
/** Blob URL for the primary model file, passed to `MMDLoader.load`. */
|
||||
modelBlobUrl: string
|
||||
/** Blob URLs for every archive entry, keyed by in-archive path. */
|
||||
blobUrls: Record<string, string>
|
||||
/**
|
||||
* Resolves an in-PMX texture reference to a blob URL.
|
||||
*
|
||||
* Install on the loader's `LoadingManager.setURLModifier`. PMX files store
|
||||
* textures as relative paths, but the model is loaded from a `blob:` URL
|
||||
* whose base cannot resolve those relatives, so we match by basename (and
|
||||
* fall back to a normalized full path). Texture basenames are effectively
|
||||
* unique within a single model, which makes this reliable in practice.
|
||||
*/
|
||||
urlModifier: UrlModifier
|
||||
/** Revokes every blob URL allocated for this load. */
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const MODEL_EXTS: Record<string, MMDModelFormat> = { '.pmx': 'pmx', '.pmd': 'pmd' }
|
||||
|
||||
function basename(path: string): string {
|
||||
const norm = path.replace(/\\/g, '/')
|
||||
const slash = norm.lastIndexOf('/')
|
||||
return slash === -1 ? norm : norm.slice(slash + 1)
|
||||
}
|
||||
|
||||
function stripExt(name: string): string {
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot === -1 ? name : name.slice(0, dot)
|
||||
}
|
||||
|
||||
function modelFormatOf(path: string): MMDModelFormat | undefined {
|
||||
const lower = path.toLowerCase()
|
||||
for (const [ext, format] of Object.entries(MODEL_EXTS)) {
|
||||
if (lower.endsWith(ext))
|
||||
return format
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Detects every PMX/PMD model file inside a zip's entry list. */
|
||||
export function detectMMDVariants(paths: string[]): MMDModelVariant[] {
|
||||
const variants: MMDModelVariant[] = []
|
||||
for (const path of paths) {
|
||||
const format = modelFormatOf(path)
|
||||
if (!format)
|
||||
continue
|
||||
variants.push({ name: stripExt(basename(path)), modelPath: path, format })
|
||||
}
|
||||
return variants
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a basename-keyed resolver from the archive's blob URLs.
|
||||
*
|
||||
* PMX texture references can include subdirectories and backslashes; we
|
||||
* normalize the requested URL down to its basename and look that up, with a
|
||||
* normalized full-path fallback for the rare case of duplicate basenames.
|
||||
*/
|
||||
function createUrlModifier(blobUrls: Record<string, string>): UrlModifier {
|
||||
const byBasename = new Map<string, string>()
|
||||
const byPath = new Map<string, string>()
|
||||
for (const [path, url] of Object.entries(blobUrls)) {
|
||||
byPath.set(path.replace(/\\/g, '/').toLowerCase(), url)
|
||||
byBasename.set(basename(path).toLowerCase(), url)
|
||||
}
|
||||
|
||||
return (requested: string) => {
|
||||
const clean = requested.split(/[?#]/)[0]
|
||||
let decoded = clean
|
||||
try {
|
||||
decoded = decodeURIComponent(clean)
|
||||
}
|
||||
catch {}
|
||||
|
||||
const normalizedPath = decoded.replace(/\\/g, '/').toLowerCase()
|
||||
for (const [path, url] of byPath) {
|
||||
if (normalizedPath.endsWith(path))
|
||||
return url
|
||||
}
|
||||
|
||||
const base = basename(decoded).toLowerCase()
|
||||
return byBasename.get(base) ?? requested
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an MMD model packaged as a ZIP (the common distribution format: a
|
||||
* `.pmx`/`.pmd` plus its texture/toon/sphere-map files) into blob URLs ready
|
||||
* for {@link createMMDLoaderContext}.
|
||||
*
|
||||
* Call `dispose()` on unmount or reload to revoke the blob URLs.
|
||||
*/
|
||||
export async function loadMMDZip(file: File | Blob | ArrayBuffer): Promise<MMDLoadedAssets> {
|
||||
const zip = new JSZip()
|
||||
const archive = await zip.loadAsync(file)
|
||||
|
||||
const paths = Object.keys(archive.files).filter(name => !archive.files[name].dir)
|
||||
const variants = detectMMDVariants(paths)
|
||||
if (variants.length === 0)
|
||||
throw new Error('MMD ZIP must contain a .pmx or .pmd model file')
|
||||
|
||||
const blobUrls: Record<string, string> = {}
|
||||
await Promise.all(paths.map(async (path) => {
|
||||
const entry = archive.files[path]
|
||||
if (!entry)
|
||||
return
|
||||
const blob = await entry.async('blob')
|
||||
blobUrls[path] = URL.createObjectURL(blob)
|
||||
}))
|
||||
|
||||
const variant = variants[0]
|
||||
const modelBlobUrl = blobUrls[variant.modelPath]
|
||||
|
||||
return {
|
||||
variant,
|
||||
variants,
|
||||
modelBlobUrl,
|
||||
blobUrls,
|
||||
urlModifier: createUrlModifier(blobUrls),
|
||||
dispose: () => {
|
||||
for (const url of Object.values(blobUrls)) {
|
||||
if (url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"jsx": "preserve",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.AsyncIterable",
|
||||
"ESNext"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -88,6 +88,7 @@
|
||||
"@proj-airi/server-sdk-shared": "workspace:^",
|
||||
"@proj-airi/stage-shared": "workspace:^",
|
||||
"@proj-airi/stage-ui-live2d": "workspace:^",
|
||||
"@proj-airi/stage-ui-mmd": "workspace:^",
|
||||
"@proj-airi/stage-ui-spine": "workspace:^",
|
||||
"@proj-airi/stage-ui-three": "workspace:^",
|
||||
"@proj-airi/ui": "workspace:^",
|
||||
|
||||
@@ -28,7 +28,15 @@ const { displayModelsFromIndexedDBLoading, displayModels } = storeToRefs(display
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleRemoveModel(model: DisplayModel) {
|
||||
const wasActive = props.selectedModel?.id === model.id
|
||||
displayModelStore.removeDisplayModel(model.id)
|
||||
// Removing the model that is currently on stage must also take it off the
|
||||
// stage; otherwise the scene keeps rendering the already-loaded mesh (its
|
||||
// blob URL stays valid). Switch to the first remaining model, or none.
|
||||
if (wasActive) {
|
||||
const fallback = displayModels.value.find(m => m.id !== model.id)
|
||||
emits('pick', fallback)
|
||||
}
|
||||
}
|
||||
|
||||
const highlightDisplayModelCard = ref<string | undefined>(props.selectedModel?.id)
|
||||
@@ -118,6 +126,37 @@ async function handleAddSpineModel(file: FileList | null) {
|
||||
highlightDisplayModelCard.value = displayModel.id
|
||||
}
|
||||
|
||||
async function handleAddMMDModel(file: FileList | null) {
|
||||
if (file === null || file.length === 0)
|
||||
return
|
||||
|
||||
const picked = file[0]
|
||||
const lower = picked.name.toLowerCase()
|
||||
// MMD distributes models as a zip (model + textures) or, less commonly, as a
|
||||
// bare .pmx/.pmd. The renderer detects zip vs raw by magic bytes at load
|
||||
// time, so the format here is mainly a label; .pmd keeps its own enum value.
|
||||
let format: DisplayModelFormat
|
||||
if (lower.endsWith('.pmd'))
|
||||
format = DisplayModelFormat.PMD
|
||||
else if (lower.endsWith('.pmx') || lower.endsWith('.zip'))
|
||||
format = DisplayModelFormat.PMXZip
|
||||
else
|
||||
return
|
||||
|
||||
// NOTICE:
|
||||
// Keep this await for the same import-then-pick race as the other formats.
|
||||
// Source/context: model selector import flow -> settings model pick -> settings-stage-model.getDisplayModel().
|
||||
// Removal condition: addDisplayModel becomes a synchronous transaction or pick is blocked by explicit import state.
|
||||
try {
|
||||
const displayModel = await displayModelStore.addDisplayModel(format, picked)
|
||||
highlightDisplayModelCard.value = displayModel.id
|
||||
}
|
||||
catch (err) {
|
||||
// Surface the failure instead of leaving the dialog looking inert.
|
||||
console.error('[model-selector] failed to import MMD model:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const mapFormatRenderer: Record<DisplayModelFormat, string> = {
|
||||
[DisplayModelFormat.Live2dZip]: 'Live2D',
|
||||
[DisplayModelFormat.Live2dDirectory]: 'Live2D',
|
||||
@@ -131,10 +170,12 @@ const mapFormatRenderer: Record<DisplayModelFormat, string> = {
|
||||
const live2dDialog = useFileDialog({ accept: '.zip', multiple: false, reset: true })
|
||||
const vrmDialog = useFileDialog({ accept: '.vrm', multiple: false, reset: true })
|
||||
const spineDialog = useFileDialog({ accept: '.zip', multiple: false, reset: true })
|
||||
const mmdDialog = useFileDialog({ accept: '.zip,.pmx,.pmd', multiple: false, reset: true })
|
||||
|
||||
live2dDialog.onChange(handleAddLive2DModel)
|
||||
vrmDialog.onChange(handleAddVRMModel)
|
||||
spineDialog.onChange(handleAddSpineModel)
|
||||
mmdDialog.onChange(handleAddMMDModel)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -205,6 +246,17 @@ spineDialog.onChange(handleAddSpineModel)
|
||||
>
|
||||
Spine
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
:class="[
|
||||
'data-[disabled]:text-mauve8 relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 leading-none outline-none data-[disabled]:pointer-events-none',
|
||||
'text-base sm:text-sm',
|
||||
'data-[highlighted]:bg-primary-300/20 dark:data-[highlighted]:bg-primary-100/20',
|
||||
'data-[highlighted]:text-primary-400 dark:data-[highlighted]:text-primary-200',
|
||||
]"
|
||||
transition="colors duration-200 ease-in-out" @click="mmdDialog.open()"
|
||||
>
|
||||
MMD
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
|
||||
@@ -15,6 +15,7 @@ withDefaults(defineProps<{
|
||||
live2dSceneClass?: string | string[]
|
||||
vrmSceneClass?: string | string[]
|
||||
spineSceneClass?: string | string[]
|
||||
mmdSceneClass?: string | string[]
|
||||
}>(), {
|
||||
allowExtractColors: true,
|
||||
})
|
||||
@@ -52,6 +53,7 @@ defineExpose({
|
||||
:live2d-scene-class="live2dSceneClass"
|
||||
:vrm-scene-class="vrmSceneClass"
|
||||
:spine-scene-class="spineSceneClass"
|
||||
:mmd-scene-class="mmdSceneClass"
|
||||
@runtime-snapshot-changed="handleRuntimeSnapshotChanged"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
<script setup lang="ts">
|
||||
import type { MMDGazeMode, MorphSlot } from '@proj-airi/stage-ui-mmd'
|
||||
|
||||
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||
|
||||
import { controlConfig, useMMD } from '@proj-airi/stage-ui-mmd'
|
||||
import { Button, FieldCheckbox, FieldCombobox, FieldRange } from '@proj-airi/ui'
|
||||
import { useFileDialog } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { Container, PropertyColor, PropertyNumber } from '../../../data-pane'
|
||||
import { Section } from '../../../layouts'
|
||||
import { ColorPalette } from '../../../widgets'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
palette: string[]
|
||||
allowExtractColors?: boolean
|
||||
runtimeSnapshot: ModelSettingsRuntimeSnapshot
|
||||
}>(), {
|
||||
allowExtractColors: true,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
(e: 'extractColorsFromModel'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const mmdStore = useMMD()
|
||||
const {
|
||||
scale,
|
||||
position,
|
||||
rotationY,
|
||||
physicsEnabled,
|
||||
ikEnabled,
|
||||
grantEnabled,
|
||||
physicsGravity,
|
||||
gazeMode,
|
||||
idleMotionName,
|
||||
availableMotions,
|
||||
availableMorphs,
|
||||
availableMaterials,
|
||||
materialOpacity,
|
||||
morphOverrides,
|
||||
cameraFov,
|
||||
ambientColor,
|
||||
ambientIntensity,
|
||||
directionalColor,
|
||||
directionalIntensity,
|
||||
directionalPosition,
|
||||
albedoGlow,
|
||||
renderScale,
|
||||
} = storeToRefs(mmdStore)
|
||||
|
||||
const canExtractColors = computed(() => props.runtimeSnapshot.canCapturePreview)
|
||||
|
||||
// VRM-style eye-tracking buttons placed in the scene grid (cols 3–5).
|
||||
const trackingOptions = computed<{ value: MMDGazeMode, label: string, class: string }[]>(() => [
|
||||
{ value: 'camera', label: t('settings.mmd.gaze.options.camera'), class: 'col-start-3' },
|
||||
{ value: 'mouse', label: t('settings.mmd.gaze.options.cursor'), class: 'col-start-4' },
|
||||
{ value: 'none', label: t('settings.mmd.gaze.options.none'), class: 'col-start-5' },
|
||||
])
|
||||
|
||||
const motionOptions = computed(() => availableMotions.value.map(motion => ({
|
||||
label: motion.name,
|
||||
value: motion.name,
|
||||
description: '',
|
||||
})))
|
||||
|
||||
// '(auto)' lets the renderer pick the morph by its standard-name candidates.
|
||||
const morphOptions = computed(() => [
|
||||
{ label: t('settings.mmd.morphs.auto'), value: '', description: '' },
|
||||
...availableMorphs.value.map(name => ({ label: name, value: name, description: '' })),
|
||||
])
|
||||
|
||||
/** Logical morph slots exposed for manual remapping, grouped by purpose. */
|
||||
const MORPH_SLOTS: { slot: MorphSlot, label: string }[] = [
|
||||
{ slot: 'vowelA', label: 'Mouth あ (A)' },
|
||||
{ slot: 'vowelI', label: 'Mouth い (I)' },
|
||||
{ slot: 'vowelU', label: 'Mouth う (U)' },
|
||||
{ slot: 'vowelE', label: 'Mouth え (E)' },
|
||||
{ slot: 'vowelO', label: 'Mouth お (O)' },
|
||||
{ slot: 'blink', label: 'Blink' },
|
||||
{ slot: 'blinkLeft', label: 'Wink (L)' },
|
||||
{ slot: 'blinkRight', label: 'Wink (R)' },
|
||||
{ slot: 'smile', label: 'Smile' },
|
||||
{ slot: 'anger', label: 'Anger' },
|
||||
{ slot: 'sad', label: 'Sad' },
|
||||
{ slot: 'surprise', label: 'Surprise' },
|
||||
{ slot: 'troubled', label: 'Troubled' },
|
||||
{ slot: 'serious', label: 'Serious' },
|
||||
]
|
||||
|
||||
function handleIdleMotionSelect(name: string | number | undefined) {
|
||||
if (typeof name !== 'string')
|
||||
return
|
||||
idleMotionName.value = name
|
||||
}
|
||||
|
||||
function setMorphOverride(slot: MorphSlot, value: string | number | undefined) {
|
||||
if (typeof value !== 'string')
|
||||
return
|
||||
morphOverrides.value = { ...morphOverrides.value, [slot]: value }
|
||||
}
|
||||
|
||||
function setMaterialOpacity(name: string, value: number) {
|
||||
materialOpacity.value = { ...materialOpacity.value, [name]: value }
|
||||
}
|
||||
|
||||
const vmdDialog = useFileDialog({ accept: '.vmd', multiple: true, reset: true })
|
||||
vmdDialog.onChange(async (files) => {
|
||||
if (!files)
|
||||
return
|
||||
for (const file of Array.from(files)) {
|
||||
const descriptor = await mmdStore.addMotion(file)
|
||||
// Make the first imported motion the idle if none is chosen yet, so the
|
||||
// character immediately animates instead of standing in rest pose.
|
||||
if (!idleMotionName.value)
|
||||
idleMotionName.value = descriptor.name
|
||||
}
|
||||
})
|
||||
|
||||
function playMotionOnce(name: string) {
|
||||
mmdStore.playOneShotAction(name, false)
|
||||
}
|
||||
|
||||
function removeMotion(id: string) {
|
||||
void mmdStore.removeMotion(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container
|
||||
:title="t('settings.pages.models.sections.section.scene')"
|
||||
icon="i-solar:people-nearby-bold-duotone"
|
||||
:class="['rounded-xl', 'bg-white/80 dark:bg-black/75', 'backdrop-blur-lg']"
|
||||
>
|
||||
<template v-if="allowExtractColors">
|
||||
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
|
||||
<Button variant="secondary" :disabled="!canExtractColors" @click="$emit('extractColorsFromModel')">
|
||||
{{ t('settings.mmd.theme-color-from-model.button-extract.title') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div grid="~ cols-5 gap-1" p-2>
|
||||
<PropertyNumber
|
||||
v-model="scale"
|
||||
:config="{ min: controlConfig.scale.min, max: controlConfig.scale.max, step: controlConfig.scale.step, label: 'Scale', formatValue: val => val?.toFixed(2) }"
|
||||
:label="t('settings.mmd.scale-and-position.scale')"
|
||||
/>
|
||||
<PropertyNumber
|
||||
v-model="position.x"
|
||||
:config="{ min: controlConfig.x.min, max: controlConfig.x.max, step: controlConfig.x.step, label: 'X', formatValue: val => val?.toFixed(2) }"
|
||||
:label="t('settings.mmd.scale-and-position.x')"
|
||||
/>
|
||||
<PropertyNumber
|
||||
v-model="position.y"
|
||||
:config="{ min: controlConfig.y.min, max: controlConfig.y.max, step: controlConfig.y.step, label: 'Y', formatValue: val => val?.toFixed(2) }"
|
||||
:label="t('settings.mmd.scale-and-position.y')"
|
||||
/>
|
||||
<PropertyNumber
|
||||
v-model="cameraFov"
|
||||
:config="{ min: 10, max: 120, step: 1, label: 'FOV' }"
|
||||
label="Camera FOV"
|
||||
/>
|
||||
<PropertyNumber
|
||||
v-model="rotationY"
|
||||
:config="{ min: controlConfig.rotationY.min, max: controlConfig.rotationY.max, step: controlConfig.rotationY.step, label: 'Rotation', formatValue: val => val?.toFixed(2) }"
|
||||
:label="t('settings.mmd.scale-and-position.rotation')"
|
||||
/>
|
||||
|
||||
<!-- Eye tracking mode -->
|
||||
<div class="text-xs">
|
||||
{{ t('settings.mmd.gaze.tracking') }}:
|
||||
</div>
|
||||
<div />
|
||||
<template v-for="option in trackingOptions" :key="option.value">
|
||||
<Button
|
||||
:class="[option.class, 'w-auto']"
|
||||
size="sm"
|
||||
:variant="gazeMode === option.value ? 'primary' : 'secondary'"
|
||||
:label="option.label"
|
||||
@click="gazeMode = option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<PropertyNumber
|
||||
v-model="directionalPosition.x"
|
||||
:config="{ min: -10, max: 10, step: 0.1, label: 'X' }"
|
||||
label="Directional Light - X"
|
||||
/>
|
||||
<PropertyNumber
|
||||
v-model="directionalPosition.y"
|
||||
:config="{ min: -10, max: 10, step: 0.1, label: 'Y' }"
|
||||
label="Directional Light - Y"
|
||||
/>
|
||||
<PropertyNumber
|
||||
v-model="directionalPosition.z"
|
||||
:config="{ min: -10, max: 10, step: 0.1, label: 'Z' }"
|
||||
label="Directional Light - Z"
|
||||
/>
|
||||
<PropertyColor
|
||||
v-model="directionalColor"
|
||||
label="Directional Light Color"
|
||||
/>
|
||||
<PropertyNumber
|
||||
v-model="directionalIntensity"
|
||||
:config="{ min: 0, max: 3, step: 0.01, label: 'Intensity' }"
|
||||
label="Directional Light Intensity"
|
||||
/>
|
||||
|
||||
<PropertyNumber
|
||||
v-model="ambientIntensity"
|
||||
:config="{ min: 0, max: 3, step: 0.01, label: 'Intensity' }"
|
||||
label="Ambient Light Intensity"
|
||||
/>
|
||||
<PropertyColor
|
||||
v-model="ambientColor"
|
||||
label="Ambient Light Color"
|
||||
/>
|
||||
|
||||
<PropertyNumber
|
||||
v-model="albedoGlow"
|
||||
:config="{ min: 0, max: 1, step: 0.01, label: 'Glow', formatValue: val => val?.toFixed(2) }"
|
||||
label="Albedo Glow"
|
||||
/>
|
||||
<PropertyNumber
|
||||
v-model="renderScale"
|
||||
:config="{ min: 0.5, max: 2, step: 0.1, label: 'Scale' }"
|
||||
label="Render Scale"
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Section
|
||||
:title="t('settings.mmd.physics.title')"
|
||||
icon="i-solar:atom-bold-duotone"
|
||||
:class="['rounded-xl', 'bg-white/80 dark:bg-black/75', 'backdrop-blur-lg']"
|
||||
size="sm"
|
||||
:expand="false"
|
||||
>
|
||||
<FieldCheckbox v-model="physicsEnabled" :label="t('settings.mmd.physics.enabled')" />
|
||||
<FieldCheckbox v-model="ikEnabled" :label="t('settings.mmd.physics.ik')" />
|
||||
<FieldCheckbox v-model="grantEnabled" :label="t('settings.mmd.physics.grant')" />
|
||||
<FieldRange
|
||||
v-model="physicsGravity"
|
||||
as="div"
|
||||
:min="0"
|
||||
:max="200"
|
||||
:step="1"
|
||||
:default-value="98"
|
||||
:label="t('settings.mmd.physics.gravity')"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
:title="t('settings.mmd.animation.title')"
|
||||
icon="i-solar:play-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="['rounded-xl', 'bg-white/80 dark:bg-black/75', 'backdrop-blur-lg']"
|
||||
size="sm"
|
||||
:expand="false"
|
||||
>
|
||||
<p :class="['mb-2', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
|
||||
{{ t('settings.mmd.animation.description') }}
|
||||
</p>
|
||||
<Button variant="secondary" :class="['mb-2']" @click="vmdDialog.open()">
|
||||
{{ t('settings.mmd.animation.import-motion') }}
|
||||
</Button>
|
||||
<FieldCombobox
|
||||
v-if="motionOptions.length > 0"
|
||||
:model-value="idleMotionName"
|
||||
:options="motionOptions"
|
||||
:label="t('settings.mmd.animation.idle-motion')"
|
||||
@update:model-value="handleIdleMotionSelect"
|
||||
/>
|
||||
<div v-if="availableMotions.length > 0" :class="['mt-2', 'flex', 'flex-col', 'gap-1']">
|
||||
<div
|
||||
v-for="motion in availableMotions"
|
||||
:key="motion.name"
|
||||
:class="['flex', 'items-center', 'justify-between', 'gap-2']"
|
||||
>
|
||||
<div :class="['truncate', 'text-sm']">
|
||||
{{ motion.name }}
|
||||
</div>
|
||||
<div :class="['flex', 'shrink-0', 'items-center', 'gap-1']">
|
||||
<Button variant="secondary" @click="playMotionOnce(motion.name)">
|
||||
{{ t('settings.mmd.animation.play-once') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:aria-label="t('settings.mmd.animation.remove')"
|
||||
@click="removeMotion(motion.id)"
|
||||
>
|
||||
<div i-solar:trash-bin-minimalistic-bold-duotone />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
:title="t('settings.mmd.morphs.title')"
|
||||
icon="i-solar:emoji-funny-square-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="['rounded-xl', 'bg-white/80 dark:bg-black/75', 'backdrop-blur-lg']"
|
||||
size="sm"
|
||||
:expand="false"
|
||||
>
|
||||
<p :class="['mb-2', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
|
||||
{{ t('settings.mmd.morphs.description') }}
|
||||
</p>
|
||||
<template v-if="availableMorphs.length > 0">
|
||||
<FieldCombobox
|
||||
v-for="entry in MORPH_SLOTS"
|
||||
:key="entry.slot"
|
||||
:model-value="morphOverrides[entry.slot] ?? ''"
|
||||
:options="morphOptions"
|
||||
:label="entry.label"
|
||||
@update:model-value="value => setMorphOverride(entry.slot, value)"
|
||||
>
|
||||
<template #label>
|
||||
<span>{{ entry.label }}</span>
|
||||
<button
|
||||
v-if="morphOverrides[entry.slot]"
|
||||
type="button"
|
||||
:title="t('settings.mmd.morphs.revert')"
|
||||
:aria-label="t('settings.mmd.morphs.revert')"
|
||||
:class="['ml-1', 'inline-flex', 'items-center']"
|
||||
@click.stop.prevent="setMorphOverride(entry.slot, '')"
|
||||
>
|
||||
<div :class="['i-solar:forward-linear', 'transform-scale-x--100', 'text-neutral-500', 'dark:text-neutral-400']" />
|
||||
</button>
|
||||
</template>
|
||||
</FieldCombobox>
|
||||
</template>
|
||||
<p v-else :class="['text-xs', 'text-amber-600', 'dark:text-amber-400']">
|
||||
{{ t('settings.mmd.morphs.no-morphs') }}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
:title="t('settings.mmd.materials.title')"
|
||||
icon="i-solar:layers-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="['rounded-xl', 'bg-white/80 dark:bg-black/75', 'backdrop-blur-lg']"
|
||||
size="sm"
|
||||
:expand="false"
|
||||
>
|
||||
<p :class="['mb-2', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
|
||||
{{ t('settings.mmd.materials.description') }}
|
||||
</p>
|
||||
<template v-if="availableMaterials.length > 0">
|
||||
<FieldRange
|
||||
v-for="material in availableMaterials"
|
||||
:key="material.index"
|
||||
as="div"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:default-value="1"
|
||||
:model-value="materialOpacity[material.name] ?? 1"
|
||||
:label="material.label"
|
||||
@update:model-value="value => setMaterialOpacity(material.name, value)"
|
||||
/>
|
||||
</template>
|
||||
<p v-else :class="['text-xs', 'text-amber-600', 'dark:text-amber-400']">
|
||||
{{ t('settings.mmd.materials.no-materials') }}
|
||||
</p>
|
||||
</Section>
|
||||
</template>
|
||||
@@ -15,6 +15,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Godot from './godot.vue'
|
||||
import Live2D from './live2d.vue'
|
||||
import MMD from './mmd.vue'
|
||||
import Spine from './spine.vue'
|
||||
import VRM from './vrm.vue'
|
||||
|
||||
@@ -83,6 +84,9 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
|
||||
<template #vrm>
|
||||
<code>.vrm</code>
|
||||
</template>
|
||||
<template #mmd>
|
||||
<code>.pmx</code>/<code>.pmd</code>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<p>
|
||||
{{ t('settings.model-select.panel-callout.model-type-example') }}
|
||||
@@ -117,6 +121,13 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="$emit('extractColorsFromModel')"
|
||||
/>
|
||||
<MMD
|
||||
v-if="effectiveRenderer === 'mmd'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="emit('extractColorsFromModel')"
|
||||
/>
|
||||
<Godot
|
||||
v-if="effectiveRenderer === 'godot'"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||
|
||||
import { Live2DScene } from '@proj-airi/stage-ui-live2d'
|
||||
import { MMDScene } from '@proj-airi/stage-ui-mmd'
|
||||
import { SpineScene } from '@proj-airi/stage-ui-spine'
|
||||
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
|
||||
import { useMouse } from '@vueuse/core'
|
||||
@@ -18,6 +19,7 @@ const props = defineProps<{
|
||||
live2dSceneClass?: string | string[]
|
||||
vrmSceneClass?: string | string[]
|
||||
spineSceneClass?: string | string[]
|
||||
mmdSceneClass?: string | string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -29,8 +31,10 @@ const modelStore = useModelStore()
|
||||
const live2dSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
|
||||
const vrmSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
|
||||
const spineSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
|
||||
const mmdSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
|
||||
const live2dComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
|
||||
const spineComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
|
||||
const mmdComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
|
||||
const vrmPreviewStageInstanceId = `model-settings-preview-stage:${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
const {
|
||||
@@ -53,6 +57,7 @@ const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore)
|
||||
const live2dSceneClassList = computed(() => normalizeClassList(props.live2dSceneClass))
|
||||
const vrmSceneClassList = computed(() => normalizeClassList(props.vrmSceneClass))
|
||||
const spineSceneClassList = computed(() => normalizeClassList(props.spineSceneClass))
|
||||
const mmdSceneClassList = computed(() => normalizeClassList(props.mmdSceneClass))
|
||||
|
||||
function normalizeClassList(value?: string | string[]) {
|
||||
if (!value)
|
||||
@@ -80,6 +85,9 @@ async function capturePreviewFrame() {
|
||||
if (stageModelRenderer.value === 'spine')
|
||||
return captureCanvasFrame(spineSceneRef.value?.canvasElement())
|
||||
|
||||
if (stageModelRenderer.value === 'mmd')
|
||||
return captureCanvasFrame(mmdSceneRef.value?.canvasElement())
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -126,6 +134,20 @@ const runtimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (stageModelRenderer.value === 'mmd') {
|
||||
const phase = resolveComponentStateToRuntimePhase(mmdComponentState.value, { hasModel })
|
||||
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: vrmPreviewStageInstanceId,
|
||||
renderer: 'mmd',
|
||||
phase,
|
||||
controlsLocked: hasModel ? phase !== 'mounted' : false,
|
||||
previewAvailable: hasModel,
|
||||
canCapturePreview: !!mmdSceneRef.value?.canvasElement(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
if (stageModelRenderer.value === 'godot') {
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: vrmPreviewStageInstanceId,
|
||||
@@ -191,4 +213,16 @@ const cursorPosition = computed(() => ({
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="stageModelRenderer === 'mmd'">
|
||||
<div :class="mmdSceneClassList">
|
||||
<MMDScene
|
||||
ref="mmdSceneRef"
|
||||
v-model:state="mmdComponentState"
|
||||
:model-src="stageModelSelectedUrl"
|
||||
:model-id="stageModelSelected"
|
||||
:cursor-position="cursorPosition"
|
||||
:enable-orbit-controls="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { StageAvatarBoundsPayload, StageViewState } from '@proj-airi/stage-
|
||||
|
||||
import type { StageModelRenderer } from '../../../../stores/settings/stage-model'
|
||||
|
||||
export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'spine' | 'godot'
|
||||
export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'spine' | 'mmd' | 'godot'
|
||||
export type ModelSettingsRuntimePhase = 'pending' | 'loading' | 'binding' | 'mounted' | 'no-model' | 'error'
|
||||
|
||||
export interface ModelSettingsRuntimeSnapshot {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createLive2DLipSync } from '@proj-airi/model-driver-lipsync'
|
||||
import { wlipsyncProfile } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
|
||||
import { createPlaybackManager, createSpeechPipeline, normalizeActPayload } from '@proj-airi/pipelines-audio'
|
||||
import { Live2DScene, useLive2dParams } from '@proj-airi/stage-ui-live2d'
|
||||
import { MMDScene } from '@proj-airi/stage-ui-mmd'
|
||||
import { SpineScene } from '@proj-airi/stage-ui-spine'
|
||||
import { ThreeScene } from '@proj-airi/stage-ui-three'
|
||||
import { animations } from '@proj-airi/stage-ui-three/assets/vrm'
|
||||
@@ -65,6 +66,7 @@ const { getDb } = useDuckDb()
|
||||
const vrmViewerRef = ref<InstanceType<typeof ThreeScene>>()
|
||||
const live2dSceneRef = ref<InstanceType<typeof Live2DScene>>()
|
||||
const spineSceneRef = ref<InstanceType<typeof SpineScene>>()
|
||||
const mmdSceneRef = ref<InstanceType<typeof MMDScene>>()
|
||||
|
||||
const settingsStore = useSettings()
|
||||
const {
|
||||
@@ -198,6 +200,9 @@ const emotionsQueue = createQueue<EmotionPayload>({
|
||||
else if (stageModelRenderer.value === 'spine') {
|
||||
spineSceneRef.value?.setEmotion(ctx.data.name, ctx.data.intensity)
|
||||
}
|
||||
else if (stageModelRenderer.value === 'mmd') {
|
||||
mmdSceneRef.value?.setEmotion(ctx.data.name, ctx.data.intensity)
|
||||
}
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -901,6 +906,9 @@ function canvasElement() {
|
||||
|
||||
else if (stageModelRenderer.value === 'spine')
|
||||
return spineSceneRef.value?.canvasElement()
|
||||
|
||||
else if (stageModelRenderer.value === 'mmd')
|
||||
return mmdSceneRef.value?.canvasElement()
|
||||
}
|
||||
|
||||
function readRenderTargetRegionAtClientPoint(clientX: number, clientY: number, radius: number) {
|
||||
@@ -915,7 +923,9 @@ async function captureFrame() {
|
||||
? live2dSceneRef.value?.captureFrame()
|
||||
: stageModelRenderer.value === 'vrm'
|
||||
? vrmViewerRef.value?.captureFrame()
|
||||
: spineSceneRef.value?.captureFrame())
|
||||
: stageModelRenderer.value === 'mmd'
|
||||
? mmdSceneRef.value?.captureFrame()
|
||||
: spineSceneRef.value?.captureFrame())
|
||||
|
||||
if (!activeBackgroundUrl.value || !charBlob)
|
||||
return charBlob
|
||||
@@ -1049,6 +1059,20 @@ defineExpose({
|
||||
:max-fps="spineMaxFps"
|
||||
:render-scale="spineRenderScale"
|
||||
/>
|
||||
<MMDScene
|
||||
v-if="stageModelRenderer === 'mmd' && showStage"
|
||||
ref="mmdSceneRef"
|
||||
v-model:state="componentState"
|
||||
min-w="50% <lg:full" min-h="100 sm:100"
|
||||
h-full w-full flex-1
|
||||
:model-src="stageModelSelectedUrl"
|
||||
:model-id="stageModelSelected"
|
||||
:paused="paused"
|
||||
:cursor-position="cursorPosition"
|
||||
:enable-orbit-controls="props.enableOrbitControls"
|
||||
:current-audio-source="currentAudioSource"
|
||||
@error="console.error"
|
||||
/>
|
||||
<div
|
||||
v-if="stageModelRenderer === 'godot'"
|
||||
:class="[
|
||||
|
||||
@@ -60,6 +60,7 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
let generateLive2DPreview: (file: File) => Promise<string | undefined>
|
||||
let generateVrmPreview: (file: File) => Promise<string | undefined>
|
||||
let generateSpinePreview: (file: File) => Promise<string | undefined>
|
||||
let generateMMDPreview: (file: File) => Promise<string | undefined>
|
||||
|
||||
const displayModelsFromIndexedDBLoading = ref(false)
|
||||
|
||||
@@ -108,6 +109,7 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
const loadLive2DModelPreview = (file: File) => generateLive2DPreview(file)
|
||||
const loadVrmModelPreview = (file: File) => generateVrmPreview(file)
|
||||
const loadSpineModelPreview = (file: File) => generateSpinePreview(file)
|
||||
const loadMMDModelPreview = (file: File) => generateMMDPreview(file)
|
||||
|
||||
async function addDisplayModel(format: DisplayModelFormat, file: File) {
|
||||
await until(displayModelsFromIndexedDBLoading).toBe(false)
|
||||
@@ -125,6 +127,22 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
const previewImage = await loadSpineModelPreview(file)
|
||||
newDisplayModel.previewImage = previewImage
|
||||
}
|
||||
else if (format === DisplayModelFormat.PMXZip || format === DisplayModelFormat.PMXDirectory || format === DisplayModelFormat.PMD) {
|
||||
// NOTICE:
|
||||
// Preview generation is best-effort and must not block the import.
|
||||
// MMD preview spins up an offscreen WebGL context and the three-stdlib
|
||||
// MMDLoader; if that throws (context limits, parse error, missing Ammo
|
||||
// module), the model should still import — just without a thumbnail.
|
||||
// Removal condition: preview generation is guaranteed non-throwing.
|
||||
try {
|
||||
if (!generateMMDPreview)
|
||||
throw new Error('MMD preview module not initialized')
|
||||
newDisplayModel.previewImage = await loadMMDModelPreview(file)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[display-models] MMD preview generation failed; importing without a thumbnail:', err)
|
||||
}
|
||||
}
|
||||
|
||||
displayModels.value.unshift(newDisplayModel)
|
||||
|
||||
@@ -191,6 +209,20 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
generateLive2DPreview = loadLive2DModelPreview
|
||||
generateVrmPreview = loadVrmModelPreview
|
||||
generateSpinePreview = loadSpineModelPreview
|
||||
|
||||
// NOTICE:
|
||||
// Isolate the MMD preview import. It pulls in three-stdlib's MMD modules,
|
||||
// and a module-evaluation failure here must not prevent the Live2D/VRM/
|
||||
// Spine preview functions (assigned above) from being wired up. A thrown
|
||||
// import previously aborted initialize() and silently broke all previews.
|
||||
// Removal condition: the MMD preview module is guaranteed to import.
|
||||
try {
|
||||
const { loadMMDModelPreview } = await import('@proj-airi/stage-ui-mmd/utils/mmd-preview')
|
||||
generateMMDPreview = loadMMDModelPreview
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[display-models] failed to load MMD preview module:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { computed, watch } from 'vue'
|
||||
|
||||
import { DisplayModelFormat, useDisplayModelsStore } from '../display-models'
|
||||
|
||||
export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'godot' | 'disabled' | undefined
|
||||
export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'mmd' | 'godot' | 'disabled' | undefined
|
||||
type BuiltInStageModelRenderer = Exclude<StageModelRenderer, 'godot'>
|
||||
|
||||
export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
@@ -55,6 +55,10 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
return 'vrm'
|
||||
case DisplayModelFormat.SpineZip:
|
||||
return 'spine'
|
||||
case DisplayModelFormat.PMXZip:
|
||||
case DisplayModelFormat.PMXDirectory:
|
||||
case DisplayModelFormat.PMD:
|
||||
return 'mmd'
|
||||
default:
|
||||
return 'disabled'
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ export default defineConfig(({ mode }) => {
|
||||
include: ['src/**/*.test.ts'],
|
||||
exclude: ['src/**/*.browser.test.ts'],
|
||||
env: loadEnv(mode, join(cwd(), 'packages', 'stage-ui'), ''),
|
||||
fileParallelism: false,
|
||||
hookTimeout: 20_000,
|
||||
maxWorkers: 1,
|
||||
testTimeout: 20_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+81
@@ -573,6 +573,9 @@ catalogs:
|
||||
alien-signals:
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.2
|
||||
ammojs-typed:
|
||||
specifier: ^1.0.6
|
||||
version: 1.0.6
|
||||
animejs:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@@ -972,6 +975,9 @@ catalogs:
|
||||
three:
|
||||
specifier: ^0.184.0
|
||||
version: 0.184.0
|
||||
three-stdlib:
|
||||
specifier: ^2.36.1
|
||||
version: 2.36.1
|
||||
tinyexec:
|
||||
specifier: ^1.1.1
|
||||
version: 1.2.4
|
||||
@@ -2047,6 +2053,9 @@ importers:
|
||||
'@proj-airi/stage-ui-live2d':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-ui-live2d
|
||||
'@proj-airi/stage-ui-mmd':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-ui-mmd
|
||||
'@proj-airi/stage-ui-spine':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-ui-spine
|
||||
@@ -3861,6 +3870,9 @@ importers:
|
||||
'@proj-airi/stage-ui-live2d':
|
||||
specifier: workspace:*
|
||||
version: link:../stage-ui-live2d
|
||||
'@proj-airi/stage-ui-mmd':
|
||||
specifier: workspace:*
|
||||
version: link:../stage-ui-mmd
|
||||
'@proj-airi/stage-ui-spine':
|
||||
specifier: workspace:*
|
||||
version: link:../stage-ui-spine
|
||||
@@ -4079,6 +4091,9 @@ importers:
|
||||
'@proj-airi/stage-ui-live2d':
|
||||
specifier: workspace:^
|
||||
version: link:../stage-ui-live2d
|
||||
'@proj-airi/stage-ui-mmd':
|
||||
specifier: workspace:^
|
||||
version: link:../stage-ui-mmd
|
||||
'@proj-airi/stage-ui-spine':
|
||||
specifier: workspace:^
|
||||
version: link:../stage-ui-spine
|
||||
@@ -4532,6 +4547,67 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.6(typescript@5.9.3)
|
||||
|
||||
packages/stage-ui-mmd:
|
||||
dependencies:
|
||||
'@moeru/std':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.0-beta.17
|
||||
'@proj-airi/stage-shared':
|
||||
specifier: workspace:^
|
||||
version: link:../stage-shared
|
||||
'@proj-airi/ui':
|
||||
specifier: workspace:^
|
||||
version: link:../ui
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 14.2.1(vue@3.5.32(typescript@5.9.3))
|
||||
'@xsai/tool':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.0-beta.2(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)
|
||||
ammojs-typed:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.6
|
||||
culori:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.2
|
||||
es-toolkit:
|
||||
specifier: 'catalog:'
|
||||
version: 1.45.1
|
||||
jszip:
|
||||
specifier: 'catalog:'
|
||||
version: 3.10.1
|
||||
localforage:
|
||||
specifier: 'catalog:'
|
||||
version: 1.10.0
|
||||
pinia:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
|
||||
three:
|
||||
specifier: 'catalog:'
|
||||
version: 0.184.0
|
||||
three-stdlib:
|
||||
specifier: 'catalog:'
|
||||
version: 2.36.1(three@0.184.0)
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.32(typescript@5.9.3)
|
||||
wlipsync:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.0
|
||||
devDependencies:
|
||||
'@types/culori':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.1
|
||||
'@types/three':
|
||||
specifier: 'catalog:'
|
||||
version: 0.184.0
|
||||
vitest:
|
||||
specifier: catalog:vitest
|
||||
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vue-tsc:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.6(typescript@5.9.3)
|
||||
|
||||
packages/stage-ui-spine:
|
||||
dependencies:
|
||||
'@esotericsoftware/spine-webgl':
|
||||
@@ -11719,6 +11795,9 @@ packages:
|
||||
alien-signals@3.1.2:
|
||||
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
|
||||
|
||||
ammojs-typed@1.0.6:
|
||||
resolution: {integrity: sha512-ut/tD0m5eEdlJ5KK97ma6SIEGZ4FN3AJTSVz8wl6C4EtnpVQdP70lOwgDMJKsHjTohxc2/xEZl6yJfMBZNlQ5w==}
|
||||
|
||||
animejs@4.3.6:
|
||||
resolution: {integrity: sha512-rzZ4bDc8JAtyx6hYwxj7s5M/yWfnM5qqY4hZDnhy1cWFvMb6H5/necHS2sbCY3WQTDbRLuZL10dPXSxSCFOr/w==}
|
||||
|
||||
@@ -26200,6 +26279,8 @@ snapshots:
|
||||
|
||||
alien-signals@3.1.2: {}
|
||||
|
||||
ammojs-typed@1.0.6: {}
|
||||
|
||||
animejs@4.3.6: {}
|
||||
|
||||
ansi-align@3.0.1:
|
||||
|
||||
@@ -220,6 +220,7 @@ catalog:
|
||||
'@xsai/tool': 0.5.0-beta.2
|
||||
'@xsai/utils-chat': 0.5.0-beta.2
|
||||
alien-signals: ^3.1.2
|
||||
ammojs-typed: ^1.0.6
|
||||
animejs: ^4.3.6
|
||||
async-mutex: 0.5.0
|
||||
awilix: ^13.0.3
|
||||
@@ -354,6 +355,7 @@ catalog:
|
||||
taze: ^19.11.0
|
||||
telegram: ^2.26.22
|
||||
three: ^0.184.0
|
||||
three-stdlib: ^2.36.1
|
||||
tinyexec: ^1.1.1
|
||||
tinyglobby: ^0.2.16
|
||||
tsdown: ^0.21.9
|
||||
|
||||
Reference in New Issue
Block a user