refactor(stage-ui-mmd): use @moeru/three-mmd (#2167)

This commit is contained in:
藍+85CD
2026-07-30 00:15:11 +08:00
committed by GitHub
parent f63294487f
commit e29fcb8c2a
19 changed files with 725 additions and 456 deletions
+2
View File
@@ -277,6 +277,7 @@ words:
- safetensors
- SAVEPOINT
- Screenable
- SDEF
- sdkmanager
- sensenova
- serde
@@ -315,6 +316,7 @@ words:
- tinyexec
- togetherapi
- tolist
- toon
- tresjs
- Triggerable
- truncatable
+5 -5
View File
@@ -26,11 +26,11 @@ reaching feature parity with the Live2D and VRM renderers:
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.
It builds on [`@moeru/three-mmd`](https://github.com/moeru-ai/three-mmd) for
PMX/PMD loading, VMD animation building, IK, append-bone propagation, toon
materials, outlines, and the shared runtime update order. Physics comes from
`@moeru/three-mmd-physics-ammo` and is loaded lazily, so the Ammo WASM runtime
is initialized only after a live MMD model is mounted.
## How to use
+3 -1
View File
@@ -33,11 +33,12 @@
},
"dependencies": {
"@moeru/std": "catalog:",
"@moeru/three-mmd": "catalog:",
"@moeru/three-mmd-physics-ammo": "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:",
@@ -51,6 +52,7 @@
"devDependencies": {
"@types/culori": "catalog:",
"@types/three": "catalog:",
"@vitest/browser-playwright": "catalog:vitest",
"vitest": "catalog:vitest",
"vue-tsc": "catalog:"
}
@@ -3,11 +3,10 @@
* 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).
* imperatively: the MMD runtime coordinates mixer, IK, grant, and physics 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.
*/
import type { SkinnedMesh } from 'three'
@@ -25,7 +24,6 @@ import {
Color,
DirectionalLight,
Group,
Mesh,
NoToneMapping,
PerspectiveCamera,
Quaternion,
@@ -40,7 +38,6 @@ import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import {
createGazeController,
createMMDAnimationManager,
createMMDLoaderContext,
createMorphController,
EYE_PITCH_LIMIT,
EYE_YAW_LIMIT,
@@ -52,6 +49,12 @@ import {
import { Emotion, EMOTION_VALUES } from '../../constants/emotions'
import { useMMD } from '../../stores/mmd'
import { loadMMDModelFromSource } from '../../utils/mmd-loader'
import {
applyMMDMaterialOpacity,
collectMMDMaterials,
disposeMMDObject,
setMMDMaterialGlow,
} from '../../utils/mmd-materials'
const props = withDefaults(defineProps<{
modelSrc?: string
@@ -112,9 +115,7 @@ 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
// Motion names already bound to the current model runtime.
const registeredMotions = new Set<string>()
const clock = new Clock()
let rafHandle = 0
@@ -195,60 +196,6 @@ 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 })
@@ -323,9 +270,9 @@ function renderLoop() {
return
if (animation) {
// One imperative step: animation mixer → IK → grant → physics.
// three-mmd preserves the required mixer → IK → grant → physics order.
animation.update(delta)
// Apply AIRI-owned morphs after the helper so lip-sync/expression win
// Apply AIRI-owned morphs after the runtime so lip-sync/expression win
// over any VMD mouth/expression keyframes.
emote?.update(delta)
blink.update(morphs, delta)
@@ -339,26 +286,20 @@ function renderLoop() {
}
function disposeModel() {
const runtimeDisposedByManager = animation !== undefined
if (animation) {
animation.dispose()
animation = undefined
}
if (!runtimeDisposedByManager)
resolved?.mmd.dispose()
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?.()
}
})
disposeMMDObject(modelGroup)
}
resolved?.dispose()
registeredMotions.clear()
animationLoader = undefined
modelGroup = undefined
mesh = undefined
morphs = undefined
@@ -390,8 +331,7 @@ async function syncMotions() {
}
const url = URL.createObjectURL(file)
try {
animationLoader ??= createMMDLoaderContext()
const clip = await loadMMDAnimationClip(animationLoader.loader, url, mesh)
const clip = await loadMMDAnimationClip(url, mesh)
animation.registerClip(descriptor.name, clip)
registeredMotions.add(descriptor.name)
if (clip.tracks.length === 0) {
@@ -444,8 +384,9 @@ async function loadModel(src: string) {
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.
animation = createMMDAnimationManager(resolved.mmd, { physicsEnabled: physicsEnabled.value })
// No preset idle VMD ships yet; the runtime still advances solvers and
// physics without an animation action.
await animation.init()
animation.setIKEnabled(ikEnabled.value)
animation.setGrantEnabled(grantEnabled.value)
@@ -454,9 +395,9 @@ async function loadModel(src: string) {
// Ensure the camera aspect matches the live canvas before fitting.
resize()
frameCamera()
applyMaterialGlow(albedoGlow.value)
mmdStore.availableMaterials = collectMaterials()
applyMaterialOpacity()
setMMDMaterialGlow(modelGroup, albedoGlow.value)
mmdStore.availableMaterials = collectMMDMaterials(modelGroup)
applyMMDMaterialOpacity(modelGroup, materialOpacity.value)
mmdStore.isModelLoaded = true
componentState.value = 'mounted'
@@ -465,6 +406,7 @@ async function loadModel(src: string) {
await syncMotions()
}
catch (err) {
disposeModel()
componentState.value = 'pending'
console.error('[mmd] failed to load model:', errorMessageFrom(err))
emit('error', err)
@@ -587,8 +529,14 @@ watch(renderScale, () => {
renderer?.setPixelRatio(Math.min(window.devicePixelRatio, 2) * renderScale.value)
resize()
})
watch(albedoGlow, () => applyMaterialGlow(albedoGlow.value))
watch(materialOpacity, () => applyMaterialOpacity(), { deep: true })
watch(albedoGlow, () => {
if (modelGroup)
setMMDMaterialGlow(modelGroup, albedoGlow.value)
})
watch(materialOpacity, () => {
if (modelGroup)
applyMMDMaterialOpacity(modelGroup, materialOpacity.value)
}, { deep: true })
defineExpose({
canvasElement,
@@ -0,0 +1,128 @@
import type { MMDUpdateOptions, PhysicsFactory } from '@moeru/three-mmd'
import type { AnimationMixer } from 'three'
import { MMD, PmxObject } from '@moeru/three-mmd'
import {
AnimationClip,
BufferGeometry,
MeshBasicMaterial,
Skeleton,
SkinnedMesh,
Vector3,
} from 'three'
import { describe, expect, it } from 'vitest'
import { createMMDAnimationManager } from './animation-manager'
function createPmx(): PmxObject {
return {
bones: [],
displayFrames: [],
header: {
additionalVec4Count: 0,
boneIndexSize: 4,
comment: '',
encoding: PmxObject.Header.Encoding.Utf8,
englishComment: '',
englishModelName: '',
materialIndexSize: 4,
modelName: '',
morphIndexSize: 4,
rigidBodyIndexSize: 4,
signature: 'PMX',
textureIndexSize: 4,
version: 2,
vertexIndexSize: 4,
},
indices: new Uint8Array(),
joints: [],
materials: [],
morphs: [],
rigidBodies: [],
softBodies: [],
textures: [],
vertices: [],
}
}
class RecordingMMD extends MMD {
disposed = false
gravity = new Vector3()
mixer?: AnimationMixer
updates: MMDUpdateOptions[] = []
constructor() {
const mesh = new SkinnedMesh(new BufferGeometry(), new MeshBasicMaterial())
mesh.bind(new Skeleton())
super(createPmx(), mesh)
}
override setPhysics(_createPhysics: PhysicsFactory): void {
this.physics = {
createHelper: () => {
throw new Error('No physics helper is used by this test runtime')
},
setGravity: gravity => this.gravity.copy(gravity),
update: () => {},
}
}
override updateWithMixer(delta: number, mixer: AnimationMixer, options: MMDUpdateOptions = {}): void {
this.mixer = mixer
this.updates.push({ ...options })
mixer.update(delta)
}
override dispose(): void {
this.disposed = true
super.dispose()
}
}
describe('createMMDAnimationManager', () => {
it('forwards runtime feature gates, gravity, and disposal to MMD', async () => {
const mmd = new RecordingMMD()
const manager = createMMDAnimationManager(mmd, { physicsEnabled: false })
manager.setGravity(3.5)
manager.setIKEnabled(false)
await manager.init()
manager.update(1 / 60)
expect(mmd.gravity.toArray()).toEqual([0, -3.5, 0])
expect(mmd.updates).toEqual([
{ grant: true, ik: false, physics: false },
])
manager.setPhysicsEnabled(true)
manager.setGrantEnabled(false)
manager.update(1 / 30)
expect(mmd.updates[1]).toEqual({ grant: false, ik: false, physics: true })
manager.dispose()
manager.update(1)
expect(mmd.disposed).toBe(true)
expect(mmd.updates).toHaveLength(2)
})
it('returns a completed one-shot action to the configured idle motion', async () => {
const mmd = new RecordingMMD()
const manager = createMMDAnimationManager(mmd)
const idle = new AnimationClip('idle', 1, [])
const gesture = new AnimationClip('gesture', 0.1, [])
manager.registerClip('idle', idle)
manager.registerClip('gesture', gesture)
await manager.init()
manager.setIdleMotion('idle', 0)
manager.playAction('gesture', { crossfade: 0 })
manager.update(0.2)
const idleAction = mmd.mixer?.existingAction(idle)
const gestureAction = mmd.mixer?.existingAction(gesture)
expect(idleAction?.isRunning()).toBe(true)
expect(gestureAction?.isRunning()).toBe(false)
})
})
@@ -1,107 +1,109 @@
import type { AnimationAction, AnimationClip, AnimationMixer, SkinnedMesh } from 'three'
import type { MMD } from '@moeru/three-mmd'
import type { AnimationAction, AnimationClip } from 'three'
import { AnimationClip as AnimationClipCtor, LoopOnce, LoopRepeat, Vector3 } from 'three'
import { MMDAnimationHelper } from 'three-stdlib'
import { AnimationMixer, LoopOnce, LoopRepeat, Vector3 } from 'three'
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. */
/**
* Initial physics enablement. Ammo is still installed so physics can be
* enabled later without rebuilding the model runtime.
*
* @default true
*/
physicsEnabled?: boolean
}
export interface PlayActionOptions {
/** Loop the action instead of reverting to idle when it finishes. */
/**
* Loop the action instead of reverting to idle when it finishes.
*
* @default false
*/
loop?: boolean
/** Cross-fade duration in seconds. */
/**
* Cross-fade duration in seconds.
*
* @default 0.4
*/
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.
* Owns a model's animation mixer, imported VMD catalog, solver feature gates,
* and lazily installed Ammo runtime.
*
* 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.
* `update(delta)` must run once per frame before AIRI-owned expression,
* lip-sync, blink, and gaze overrides. Disposal stops actions before ending
* the MMD runtime so no frame can observe a partially torn-down model.
*/
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 })
export function createMMDAnimationManager(mmd: MMD, options: MMDAnimationManagerOptions = {}) {
const mixer = new AnimationMixer(mmd.mesh)
const registry = new Map<string, AnimationClip>()
const finishListeners = new Map<AnimationAction, (event: { action: AnimationAction }) => void>()
let mixer: AnimationMixer | undefined
let idleClip: AnimationClip | undefined
let idleAction: AnimationAction | undefined
let currentAction: AnimationAction | undefined
let initialized = false
let disposed = false
let initialization: Promise<void> | undefined
let physicsEnabled = options.physicsEnabled ?? true
let ikEnabled = true
let grantEnabled = true
let gravity: number | undefined
function getMixer(): AnimationMixer | undefined {
if (!mixer)
mixer = helper.objects.get(mesh)?.mixer
return mixer
function removeFinishListener(action: AnimationAction): void {
const listener = finishListeners.get(action)
if (!listener)
return
mixer.removeEventListener('finished', listener)
finishListeners.delete(action)
}
function revertToIdleOnFinish(action: AnimationAction) {
const m = getMixer()
if (!m)
return
function revertToIdleOnFinish(action: AnimationAction): void {
removeFinishListener(action)
const onFinished = (event: { action: AnimationAction }) => {
if (event.action !== action)
return
m.removeEventListener('finished', onFinished)
removeFinishListener(action)
playIdle()
}
m.addEventListener('finished', onFinished)
finishListeners.set(action, onFinished)
mixer.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.
* Installs the lazy Ammo backend and optionally starts an initial idle clip.
* Concurrent calls share one initialization; disposal while Ammo loads
* prevents the completed promise from reviving the manager.
*/
async function init(idle?: AnimationClip): Promise<void> {
if (initialized)
if (initialized || disposed)
return
if (initialization)
return initialization
await ensureAmmo()
initialization = (async () => {
const createPhysics = await ensureAmmo()
if (disposed)
return
// 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, [])
mmd.setPhysics(createPhysics)
if (gravity !== undefined)
mmd.physics?.setGravity?.(new Vector3(0, -gravity, 0))
helper.add(mesh, {
animation: idleClip,
physics: true,
})
if (idle) {
idleAction = mixer.clipAction(idle)
idleAction.setLoop(LoopRepeat, Number.POSITIVE_INFINITY).play()
}
currentAction = idleAction
initialized = 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
return initialization
}
/** Registers a VMD-derived clip under a name for later playback. */
@@ -116,134 +118,138 @@ export function createMMDAnimationManager(mesh: SkinnedMesh, options: MMDAnimati
/** Cross-fades back to the persistent idle loop. */
function playIdle(crossfade = DEFAULT_CROSSFADE): void {
const m = getMixer()
if (!m)
return
if (currentAction)
removeFinishListener(currentAction)
// 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.
// With no configured idle, fade the active motion out so the skeleton
// returns to its rest pose instead of clamping on the final keyframe.
if (!idleAction) {
if (currentAction)
currentAction.fadeOut(crossfade)
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.
// A one-shot may reuse the same action, so restore its looping contract.
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.
* Plays a registered motion and cross-fades from the current action.
* One-shots return to idle; looping actions remain active until replaced.
*
* Returns `false` when the name is not registered so callers can fall back.
* @returns `false` when no clip is registered under `name`.
*/
function playAction(name: string, opts: PlayActionOptions = {}): boolean {
const m = getMixer()
function playAction(name: string, actionOptions: PlayActionOptions = {}): boolean {
const clip = registry.get(name)
if (!m || !clip) {
console.warn(`[mmd] playAction skipped: "${name}" is ${clip ? 'present' : 'not registered'}, mixer ${m ? 'ready' : 'missing'}`)
if (!clip) {
console.warn(`[mmd] playAction skipped: "${name}" is not registered`)
return false
}
const loop = opts.loop ?? false
const crossfade = opts.crossfade ?? DEFAULT_CROSSFADE
const action = m.clipAction(clip)
const loop = actionOptions.loop ?? false
const crossfade = actionOptions.crossfade ?? DEFAULT_CROSSFADE
const action = mixer.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)
if (currentAction && currentAction !== action) {
removeFinishListener(currentAction)
currentAction.fadeOut(crossfade)
}
currentAction = action
if (!loop)
if (loop)
removeFinishListener(action)
else
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.
* Makes a registered motion the looping base that one-shots return to.
*
* Returns `false` when the name is not registered.
* @returns `false` when no clip is registered under `name`.
*/
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'}`)
if (!clip) {
console.warn(`[mmd] setIdleMotion skipped: "${name}" is not registered`)
return false
}
const action = m.clipAction(clip)
const action = mixer.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
const previousIdle = idleAction
idleAction = action
if (currentAction && currentAction !== action)
if (currentAction && currentAction !== action) {
removeFinishListener(currentAction)
currentAction.fadeOut(crossfade)
else if (previous && previous !== action)
previous.fadeOut(crossfade)
}
else if (previousIdle && previousIdle !== action) {
previousIdle.fadeOut(crossfade)
}
currentAction = action
return true
}
function setPhysicsEnabled(enabled: boolean): void {
helper.enable('physics', enabled)
physicsEnabled = enabled
}
/** Sets the physics world gravity strength, applied as (0, -magnitude, 0). */
/** Sets physics gravity to `(0, -magnitude, 0)`, including during init. */
function setGravity(magnitude: number): void {
helper.objects.get(mesh)?.physics?.setGravity(new Vector3(0, -magnitude, 0))
gravity = magnitude
mmd.physics?.setGravity?.(new Vector3(0, -magnitude, 0))
}
function setIKEnabled(enabled: boolean): void {
helper.enable('ik', enabled)
ikEnabled = enabled
}
function setGrantEnabled(enabled: boolean): void {
helper.enable('grant', enabled)
grantEnabled = enabled
}
function update(delta: number): void {
if (!initialized)
if (!initialized || disposed)
return
helper.update(delta)
mmd.updateWithMixer(delta, mixer, {
grant: grantEnabled,
ik: ikEnabled,
physics: physicsEnabled,
})
}
function dispose(): void {
const m = getMixer()
m?.stopAllAction()
if (initialized) {
try {
helper.remove(mesh)
}
catch {}
}
if (disposed)
return
disposed = true
for (const listener of finishListeners.values())
mixer.removeEventListener('finished', listener)
finishListeners.clear()
mixer.stopAllAction()
mixer.uncacheRoot(mmd.mesh)
mmd.dispose()
registry.clear()
mixer = undefined
idleAction = undefined
currentAction = undefined
initialized = false
}
return {
helper,
init,
registerClip,
availableClips,
@@ -50,12 +50,11 @@ export interface GazeController {
* 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.
* after the MMD runtime 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
* append/grant solver, which runs *inside* the runtime 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.
*
@@ -1,7 +1,8 @@
import type { MMD } from '@moeru/three-mmd'
import type { AnimationClip, SkinnedMesh } from 'three'
import { buildAnimation, MMDLoader, VMDLoader } from '@moeru/three-mmd'
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
@@ -43,43 +44,24 @@ export function createMMDLoaderContext(urlModifier?: UrlModifier): MMDLoaderCont
return { loader: new MMDLoader(manager), manager }
}
/** Loads a PMX/PMD model URL into a {@link SkinnedMesh}. */
export function loadMMDMesh(
/** Loads a PMX/PMD model URL while retaining its MMD runtime. */
export function loadMMD(
loader: MMDLoader,
url: string,
onProgress?: (event: ProgressEvent) => void,
): Promise<SkinnedMesh> {
return new Promise((resolve, reject) => {
loader.load(url, resolve, onProgress, reject)
})
): Promise<MMD> {
return loader.loadAsync(url, onProgress)
}
/**
* 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.
* Parses a VMD model motion and binds its bone and morph tracks to `mesh`.
* AIRI intentionally does not consume camera motion from this adapter.
*/
export function loadMMDAnimationClip(
loader: MMDLoader,
export async function loadMMDAnimationClip(
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,
)
})
const vmd = await new VMDLoader().loadAsync(url, onProgress)
return buildAnimation(vmd, mesh)
}
@@ -49,9 +49,9 @@ export interface MorphController {
* 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.
* Managed weights must be written after the MMD runtime update each frame: a
* VMD clip can also key morph influences, and AIRI's lip-sync/expression must
* win for the slots it owns.
*/
export function createMorphController(
mesh: SkinnedMesh,
+12 -42
View File
@@ -1,49 +1,19 @@
import type Ammo from 'ammojs-typed'
import type { PhysicsFactory } from '@moeru/three-mmd'
let physics: Promise<PhysicsFactory> | undefined
/**
* Lazily initializes the Ammo.js (Bullet) physics runtime and exposes it as
* the global `Ammo` that three-stdlib's `MMDPhysics` expects.
* Lazily initializes three-mmd's Ammo adapter.
*
* 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.
* The memoized promise gives every mounted model the same WASM runtime while
* leaving preview generation physics-free.
*/
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
export function ensureAmmo(): Promise<PhysicsFactory> {
physics ??= import('@moeru/three-mmd-physics-ammo')
.then(async ({ initAmmo, MMDAmmoPhysics }) => {
await initAmmo()
return MMDAmmoPhysics
})
return ammoReady
}
/** Whether the Ammo runtime has already been published on the global scope. */
export function isAmmoReady(): boolean {
return Boolean((globalThis as AmmoGlobal).Ammo)
return physics
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { loadMMDModelFromSource } from './mmd-loader'
import { disposeMMDObject } from './mmd-materials'
function createEmptyPmx(): ArrayBuffer {
// PMX 2.0 header + four empty metadata strings + nine empty data sections.
// Keeping this as a real binary exercises the loader boundary without
// bypassing fetch, parser selection, or runtime assembly.
const buffer = new ArrayBuffer(4 + 4 + 1 + 8 + 4 * 4 + 9 * 4)
const view = new DataView(buffer)
let offset = 0
for (const byte of [0x50, 0x4D, 0x58, 0x20])
view.setUint8(offset++, byte)
view.setFloat32(offset, 2, true)
offset += 4
view.setUint8(offset++, 8)
for (const global of [1, 0, 1, 1, 1, 1, 1, 1])
view.setUint8(offset++, global)
for (let index = 0; index < 4 + 9; index++) {
view.setInt32(offset, 0, true)
offset += 4
}
return buffer
}
describe('loadMMDModelFromSource', () => {
it('loads an extensionless blob URL by inspecting the PMX header', async () => {
// ROOT CAUSE:
//
// three-stdlib selected PMX/PMD from the URL suffix, so object URLs needed
// an artificial fragment. three-mmd selects the parser from binary header
// bytes, allowing AIRI to pass the original blob URL unchanged.
const objectUrl = URL.createObjectURL(new Blob([createEmptyPmx()]))
let resolved: Awaited<ReturnType<typeof loadMMDModelFromSource>> | undefined
try {
resolved = await loadMMDModelFromSource(objectUrl)
expect(resolved.mmd.mesh).toBe(resolved.mesh)
expect(resolved.format).toBe('pmx')
expect(resolved.mesh.skeleton.bones).toEqual([])
}
finally {
resolved?.mmd.dispose()
if (resolved)
disposeMMDObject(resolved.mesh)
resolved?.dispose()
URL.revokeObjectURL(objectUrl)
}
})
})
@@ -1,28 +0,0 @@
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')
})
})
+34 -116
View File
@@ -1,13 +1,16 @@
import type { Color, LoadingManager, Material, SkinnedMesh, Texture } from 'three'
import type { MMD } from '@moeru/three-mmd'
import type { LoadingManager, SkinnedMesh } from 'three'
import type { MMDLoadedAssets, MMDModelFormat } from './mmd-zip-loader'
import { Mesh, SRGBColorSpace } from 'three'
import { createMMDLoaderContext, loadMMDMesh } from '../composables/mmd/loader'
import { createMMDLoaderContext, loadMMD } from '../composables/mmd/loader'
import { prepareMMDMaterials } from './mmd-materials'
import { loadMMDZip } from './mmd-zip-loader'
export interface ResolvedMMDModel {
/** MMD runtime that owns IK, grant, morph, and optional physics state. */
mmd: MMD
/** Convenience alias for `mmd.mesh`. */
mesh: SkinnedMesh
format: MMDModelFormat
/** Present only when the source was a ZIP archive. */
@@ -23,7 +26,7 @@ export interface LoadMMDOptions {
* 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
* (the preview) would capture an un-textured/transparent frame. Enable this
* for previews. Defaults to `false`.
*/
waitForTextures?: boolean
@@ -58,109 +61,12 @@ function waitForManagerIdle(manager: LoadingManager, timeoutMs = 4000): Promise<
})
}
/** 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}.
* Loads an MMD model from an arbitrary source URL with its runtime intact.
*
* Accepts either a packaged ZIP (the usual distribution form: model plus
* textures) or a bare `.pmx`/`.pmd` URL. ZIP archives are unpacked to blob
@@ -179,27 +85,39 @@ export async function loadMMDModelFromSource(src: string, options: LoadMMDOption
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(),
let mmd: MMD | undefined
try {
const { loader, manager } = createMMDLoaderContext(assets.urlModifier)
mmd = await loadMMD(loader, assets.modelBlobUrl)
prepareMMDMaterials(mmd.mesh)
if (options.waitForTextures)
await waitForManagerIdle(manager)
return {
mmd,
mesh: mmd.mesh,
format: assets.variant.format,
assets,
dispose: () => assets.dispose(),
}
}
catch (error) {
// Runtime state must end before ZIP-owned blob URLs disappear; material
// texture requests can still refer to those URLs while loading fails.
mmd?.dispose()
assets.dispose()
throw error
}
}
// 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)
const mmd = await loadMMD(loader, src)
prepareMMDMaterials(mmd.mesh)
if (options.waitForTextures)
await waitForManagerIdle(manager)
return {
mesh,
mmd,
mesh: mmd.mesh,
format: formatFromUrl(src),
dispose: () => {},
}
@@ -0,0 +1,115 @@
import type { MMDMaterialDescriptor } from '@moeru/three-mmd/materials'
import { MMDToonMaterial } from '@moeru/three-mmd/materials/toon'
import {
BufferGeometry,
Color,
Group,
Mesh,
MeshBasicMaterial,
MeshDepthMaterial,
MeshDistanceMaterial,
MeshPhongMaterial,
Texture,
} from 'three'
import { describe, expect, it } from 'vitest'
import {
applyMMDMaterialOpacity,
collectMMDMaterials,
disposeMMDObject,
prepareMMDMaterials,
setMMDMaterialGlow,
} from './mmd-materials'
function createDescriptor(name: string, opacity = 1, transparent = false): MMDMaterialDescriptor {
return {
ambient: new Color(0.1, 0.2, 0.3),
diffuse: new Color(0.4, 0.5, 0.6),
fog: true,
isDefaultToonTexture: true,
name,
opacity,
outline: {
alpha: 0.35,
color: new Color(0.1, 0.1, 0.1),
visible: true,
width: 0.01,
},
shininess: 16,
specular: new Color(0.2, 0.3, 0.4),
toonMap: new Texture(),
toonMapFileName: 'toon01.bmp',
transparent,
}
}
function countDisposals(resource: BufferGeometry | MeshBasicMaterial | MeshDepthMaterial | MeshDistanceMaterial | MMDToonMaterial) {
let count = 0
resource.addEventListener('dispose', () => count++)
return () => count
}
describe('mmd surface materials', () => {
it('keeps generated outline materials out of the settings catalog and live controls', () => {
const root = new Group()
const surface = new MMDToonMaterial(createDescriptor('skin', 0.8, true))
const outline = new MeshPhongMaterial({ opacity: 0.35, transparent: true })
outline.name = 'skin:outline'
const mesh = new Mesh(new BufferGeometry(), surface)
mesh.add(new Mesh(mesh.geometry, outline))
root.add(mesh)
prepareMMDMaterials(root)
setMMDMaterialGlow(root, 0.7)
applyMMDMaterialOpacity(root, { 'skin': 0.4, 'skin:outline': 0.1 })
expect(collectMMDMaterials(root)).toEqual([
{ index: 0, label: 'skin', name: 'skin' },
])
expect(surface.emissiveIntensity).toBe(0.7)
expect(surface.opacity).toBe(0.4)
expect(surface.transparent).toBe(true)
expect(outline.emissiveIntensity).toBe(1)
expect(outline.opacity).toBe(0.35)
})
it('restores each surface material authored opacity and transparency', () => {
const surface = new MMDToonMaterial(createDescriptor('glass', 0.6, true))
const root = new Mesh(new BufferGeometry(), surface)
applyMMDMaterialOpacity(root, { glass: 0.2 })
applyMMDMaterialOpacity(root, {})
expect(surface.opacity).toBe(0.6)
expect(surface.transparent).toBe(true)
})
})
describe('mmd GPU resource disposal', () => {
it('disposes shared geometry, render materials, and custom shadow materials once', () => {
const geometry = new BufferGeometry()
const surface = new MMDToonMaterial(createDescriptor('surface'))
const outline = new MeshBasicMaterial()
const depth = new MeshDepthMaterial()
const distance = new MeshDistanceMaterial()
const mesh = new Mesh(geometry, surface)
mesh.customDepthMaterial = depth
mesh.customDistanceMaterial = distance
mesh.add(new Mesh(geometry, outline))
const geometryDisposals = countDisposals(geometry)
const surfaceDisposals = countDisposals(surface)
const outlineDisposals = countDisposals(outline)
const depthDisposals = countDisposals(depth)
const distanceDisposals = countDisposals(distance)
disposeMMDObject(mesh)
expect(geometryDisposals()).toBe(1)
expect(surfaceDisposals()).toBe(1)
expect(outlineDisposals()).toBe(1)
expect(depthDisposals()).toBe(1)
expect(distanceDisposals()).toBe(1)
})
})
@@ -0,0 +1,130 @@
import type { MMDToonMaterial } from '@moeru/three-mmd/materials/toon'
import type { BufferGeometry, Material, Object3D } from 'three'
import { Mesh } from 'three'
interface OriginalSurfaceState {
opacity: number
transparent: boolean
}
const originalSurfaceStates = new WeakMap<MMDToonMaterial, OriginalSurfaceState>()
function forEachMMDMaterial(root: Object3D, visit: (material: MMDToonMaterial) => void): void {
root.traverse((object) => {
if (!(object instanceof Mesh))
return
const materials = Array.isArray(object.material) ? object.material : [object.material]
for (const material of materials) {
if ('isMMDMaterial' in material && material.isMMDMaterial === true)
visit(material)
}
})
}
/**
* Applies AIRI's albedo-glow policy to loader-owned MMD surfaces.
*
* Generated outline and shadow-pass materials remain under three-mmd's
* control, while all render meshes opt out of bind-pose frustum culling.
*/
export function prepareMMDMaterials(root: Object3D, glow = 0.45): void {
root.traverse((object) => {
if (object instanceof Mesh)
object.frustumCulled = false
})
forEachMMDMaterial(root, (material) => {
if (material.map) {
material.emissiveMap = material.map
material.emissive.setScalar(1)
}
else {
material.emissiveMap = null
material.emissive.copy(material.color)
}
material.emissiveIntensity = glow
material.needsUpdate = true
})
}
/** Updates AIRI's live albedo-glow setting without modifying outline passes. */
export function setMMDMaterialGlow(root: Object3D, glow: number): void {
forEachMMDMaterial(root, (material) => {
material.emissiveIntensity = glow
})
}
/** Returns user-configurable surface materials in loader traversal order. */
export function collectMMDMaterials(root: Object3D): { name: string, label: string, index: number }[] {
const descriptors: { name: string, label: string, index: number }[] = []
forEachMMDMaterial(root, (material) => {
const index = descriptors.length
descriptors.push({
name: material.name,
label: material.name || `Material ${index}`,
index,
})
})
return descriptors
}
/**
* Applies named opacity overrides to MMD surfaces.
*
* Removing an override restores the authored opacity and transparency instead
* of forcing an opaque default. Outline alpha remains owned by three-mmd.
*/
export function applyMMDMaterialOpacity(root: Object3D, overrides: Record<string, number>): void {
forEachMMDMaterial(root, (material) => {
let original = originalSurfaceStates.get(material)
if (!original) {
original = {
opacity: material.opacity,
transparent: material.transparent,
}
originalSurfaceStates.set(material, original)
}
const override = overrides[material.name]
material.opacity = override ?? original.opacity
material.transparent = override === undefined
? original.transparent
: original.transparent || override < 1
material.needsUpdate = true
})
}
/**
* Releases GPU resources owned by an MMD object tree exactly once.
*
* three-mmd outline meshes can share geometry with their surface, while SDEF
* depth and distance materials live outside `Mesh.material`; both cases are
* accounted for explicitly.
*/
export function disposeMMDObject(root: Object3D): void {
const geometries = new Set<BufferGeometry>()
const materials = new Set<Material>()
root.traverse((object) => {
if (!(object instanceof Mesh))
return
geometries.add(object.geometry)
const renderMaterials = Array.isArray(object.material) ? object.material : [object.material]
for (const material of renderMaterials)
materials.add(material)
if (object.customDepthMaterial)
materials.add(object.customDepthMaterial)
if (object.customDistanceMaterial)
materials.add(object.customDistanceMaterial)
})
for (const geometry of geometries)
geometry.dispose()
for (const material of materials)
material.dispose()
}
+4 -17
View File
@@ -1,11 +1,8 @@
import type { Object3D } from 'three'
import {
AmbientLight,
Box3,
DirectionalLight,
Group,
Mesh,
PerspectiveCamera,
Scene,
SRGBColorSpace,
@@ -14,19 +11,7 @@ import {
} 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?.()
}
})
}
import { disposeMMDObject } from './mmd-materials'
/**
* Renders an MMD model file to an offscreen canvas and returns a preview data
@@ -84,8 +69,10 @@ export async function loadMMDModelPreview(file: File): Promise<string | undefine
return canvas.toDataURL()
}
finally {
// End runtime-owned state before releasing the GPU objects it references.
resolved?.mmd.dispose()
if (group)
disposeObject(group)
disposeMMDObject(group)
resolved?.dispose()
scene.clear()
renderer.renderLists.dispose()
+23 -1
View File
@@ -1,7 +1,29 @@
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
projects: [
{
test: {
name: 'node',
include: ['src/**/*.test.ts'],
exclude: ['src/**/*.browser.test.ts'],
},
},
{
test: {
name: 'browser',
include: ['src/**/*.browser.test.ts'],
browser: {
enabled: true,
provider: playwright(),
instances: [
{ browser: 'chromium' },
],
},
},
},
],
},
})
+40 -8
View File
@@ -240,6 +240,12 @@ catalogs:
'@moeru/std':
specifier: 0.1.0-beta.17
version: 0.1.0-beta.17
'@moeru/three-mmd':
specifier: 0.1.0-beta.6
version: 0.1.0-beta.6
'@moeru/three-mmd-physics-ammo':
specifier: 0.1.0-beta.6
version: 0.1.0-beta.6
'@napi-rs/image':
specifier: ^1.12.0
version: 1.12.0
@@ -582,9 +588,6 @@ 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
@@ -4555,6 +4558,12 @@ importers:
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
'@moeru/three-mmd':
specifier: 'catalog:'
version: 0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0)
'@moeru/three-mmd-physics-ammo':
specifier: 'catalog:'
version: 0.1.0-beta.6(@moeru/three-mmd@0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0))(@types/three@0.184.0)(three@0.184.0)
'@proj-airi/stage-shared':
specifier: workspace:^
version: link:../stage-shared
@@ -4567,9 +4576,6 @@ importers:
'@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
@@ -4604,6 +4610,9 @@ importers:
'@types/three':
specifier: 'catalog:'
version: 0.184.0
'@vitest/browser-playwright':
specifier: catalog:vitest
version: 4.1.4(bufferutil@4.1.0)(playwright@1.60.0)(utf-8-validate@5.0.10)(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))(vitest@4.1.4)
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))
@@ -7997,6 +8006,19 @@ packages:
'@moeru/std@0.1.0-beta.19':
resolution: {integrity: sha512-+VYlWHgGkU/KrYvB9Ei4v4uTW8t5noGVp8Jb9X6xAhifNtD/h05w35efA2Zy/txqklzBYbFMvd37zOBiss88jg==}
'@moeru/three-mmd-physics-ammo@0.1.0-beta.6':
resolution: {integrity: sha512-kH2Ql0VkdRp4FqLIBw23Qmw+OCuDE1vcHAh47JBUA4nOTc8cVPQM8s37qVcjKTT8EoujNBVnS9BBk4+d7Xl0Jg==}
peerDependencies:
'@moeru/three-mmd': ^0.1.0-beta.6
'@types/three': ^0.184.0
three: ^0.184.0
'@moeru/three-mmd@0.1.0-beta.6':
resolution: {integrity: sha512-gkGfZoV9ma3APac1gyPV6MLZjhO9kuhvYOsz2y/xITtuDCchrWvSyo1K2yzqi44RRJBy7U/xtpFIaweX5qFLcg==}
peerDependencies:
'@types/three': '>=0.184.0'
three: '>=0.184.0'
'@mrleebo/prisma-ast@0.13.1':
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
engines: {node: '>=16'}
@@ -22391,6 +22413,18 @@ snapshots:
'@moeru/std@0.1.0-beta.19': {}
'@moeru/three-mmd-physics-ammo@0.1.0-beta.6(@moeru/three-mmd@0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0))(@types/three@0.184.0)(three@0.184.0)':
dependencies:
'@moeru/three-mmd': 0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0)
'@types/three': 0.184.0
ammojs-typed: 1.0.6
three: 0.184.0
'@moeru/three-mmd@0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0)':
dependencies:
'@types/three': 0.184.0
three: 0.184.0
'@mrleebo/prisma-ast@0.13.1':
dependencies:
chevrotain: 10.5.0
@@ -25801,7 +25835,6 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/browser@4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)':
dependencies:
@@ -25836,7 +25869,6 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/coverage-v8@4.1.4(@vitest/browser@4.1.4)(vitest@4.1.4)':
dependencies:
+3 -3
View File
@@ -10,6 +10,7 @@ packages:
- engines/**
- apps/**
- '!**/dist/**'
overrides:
array-flatten: npm:@nolyfill/array-flatten@^1.0.44
axios: npm:feaxios@^0.0.23
@@ -20,7 +21,6 @@ overrides:
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
side-channel: npm:@nolyfill/side-channel@^1.0.44
string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44
patchedDependencies:
'@mediapipe/tasks-vision': patches/@mediapipe__tasks-vision.patch
'@xsai/generate-text@0.5.0-beta.2': patches/@xsai__generate-text@0.5.0-beta.2.patch
@@ -30,7 +30,6 @@ patchedDependencies:
pixi-live2d-display: patches/pixi-live2d-display.patch
sponsorkit@17.1.0: patches/sponsorkit@17.1.0.patch
uiohook-napi@1.5.5: patches/uiohook-napi@1.5.5.patch
catalog:
'@alexanderolsen/libsamplerate-js': ^2.1.2
'@antfu/eslint-config': ^8.2.0
@@ -110,6 +109,8 @@ catalog:
'@moeru/eslint-config': 0.1.0-beta.19
'@moeru/eventa': 1.0.0-beta.8
'@moeru/std': 0.1.0-beta.17
'@moeru/three-mmd': 0.1.0-beta.6
'@moeru/three-mmd-physics-ammo': 0.1.0-beta.6
'@napi-rs/image': ^1.12.0
'@nekopaw/tempora': 0.4.0-alpha.1
'@opentelemetry/api': ^1.9.1
@@ -224,7 +225,6 @@ 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