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:
nyueki
2026-07-20 16:13:55 +08:00
committed by GitHub
co-authored by nyueki autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Neko Ayaka
parent 78d6588cb0
commit eabec9a64f
53 changed files with 3746 additions and 10 deletions
+3
View File
@@ -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: [
+1
View File
@@ -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,
+2
View File
@@ -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,
},
})
+53 -3
View File
@@ -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: 切换角色的 Live2DVRMSpine 模型
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: 切換角色的 Live2DVRMSpine 模型
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
+1
View File
@@ -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:*",
+72
View File
@@ -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).
+57
View File
@@ -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
* 16 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.70.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
+11
View File
@@ -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'
+328
View File
@@ -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, 01. 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,
}
}
+49
View File
@@ -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 {}
}
}
},
}
}
+36
View File
@@ -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"
]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})
+1
View File
@@ -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 35).
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'
}
+4
View File
@@ -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,
},
},
{
+81
View File
@@ -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:
+2
View File
@@ -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