From e29fcb8c2aee852e821c044c98e33d2519917355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=97=8D+85CD?= <50108258+kwaa@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:15:11 +0800 Subject: [PATCH] refactor(stage-ui-mmd): use `@moeru/three-mmd` (#2167) --- cspell.config.yaml | 2 + packages/stage-ui-mmd/README.md | 10 +- packages/stage-ui-mmd/package.json | 4 +- .../src/components/scenes/MMD.vue | 120 +++------ .../composables/mmd/animation-manager.test.ts | 128 ++++++++++ .../src/composables/mmd/animation-manager.ts | 234 +++++++++--------- .../stage-ui-mmd/src/composables/mmd/gaze.ts | 5 +- .../src/composables/mmd/loader.ts | 40 +-- .../stage-ui-mmd/src/composables/mmd/morph.ts | 6 +- packages/stage-ui-mmd/src/utils/ammo.ts | 54 +--- .../src/utils/mmd-loader.browser.test.ts | 56 +++++ .../stage-ui-mmd/src/utils/mmd-loader.test.ts | 28 --- packages/stage-ui-mmd/src/utils/mmd-loader.ts | 150 +++-------- .../src/utils/mmd-materials.test.ts | 115 +++++++++ .../stage-ui-mmd/src/utils/mmd-materials.ts | 130 ++++++++++ .../stage-ui-mmd/src/utils/mmd-preview.ts | 21 +- packages/stage-ui-mmd/vitest.config.ts | 24 +- pnpm-lock.yaml | 48 +++- pnpm-workspace.yaml | 6 +- 19 files changed, 725 insertions(+), 456 deletions(-) create mode 100644 packages/stage-ui-mmd/src/composables/mmd/animation-manager.test.ts create mode 100644 packages/stage-ui-mmd/src/utils/mmd-loader.browser.test.ts delete mode 100644 packages/stage-ui-mmd/src/utils/mmd-loader.test.ts create mode 100644 packages/stage-ui-mmd/src/utils/mmd-materials.test.ts create mode 100644 packages/stage-ui-mmd/src/utils/mmd-materials.ts diff --git a/cspell.config.yaml b/cspell.config.yaml index ae601a00f..8cc9a934c 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -277,6 +277,7 @@ words: - safetensors - SAVEPOINT - Screenable + - SDEF - sdkmanager - sensenova - serde @@ -315,6 +316,7 @@ words: - tinyexec - togetherapi - tolist + - toon - tresjs - Triggerable - truncatable diff --git a/packages/stage-ui-mmd/README.md b/packages/stage-ui-mmd/README.md index 74edb7358..05a34f353 100644 --- a/packages/stage-ui-mmd/README.md +++ b/packages/stage-ui-mmd/README.md @@ -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 diff --git a/packages/stage-ui-mmd/package.json b/packages/stage-ui-mmd/package.json index 44de8c795..e825a793c 100644 --- a/packages/stage-ui-mmd/package.json +++ b/packages/stage-ui-mmd/package.json @@ -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:" } diff --git a/packages/stage-ui-mmd/src/components/scenes/MMD.vue b/packages/stage-ui-mmd/src/components/scenes/MMD.vue index ff4acb8d5..bcc42102a 100644 --- a/packages/stage-ui-mmd/src/components/scenes/MMD.vue +++ b/packages/stage-ui-mmd/src/components/scenes/MMD.vue @@ -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 | 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 | undefined +// Motion names already bound to the current model runtime. const registeredMotions = new Set() 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, diff --git a/packages/stage-ui-mmd/src/composables/mmd/animation-manager.test.ts b/packages/stage-ui-mmd/src/composables/mmd/animation-manager.test.ts new file mode 100644 index 000000000..74b0b4679 --- /dev/null +++ b/packages/stage-ui-mmd/src/composables/mmd/animation-manager.test.ts @@ -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) + }) +}) diff --git a/packages/stage-ui-mmd/src/composables/mmd/animation-manager.ts b/packages/stage-ui-mmd/src/composables/mmd/animation-manager.ts index 3b0c5221e..497aacc60 100644 --- a/packages/stage-ui-mmd/src/composables/mmd/animation-manager.ts +++ b/packages/stage-ui-mmd/src/composables/mmd/animation-manager.ts @@ -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() + const finishListeners = new Map 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 | 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 { - 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, diff --git a/packages/stage-ui-mmd/src/composables/mmd/gaze.ts b/packages/stage-ui-mmd/src/composables/mmd/gaze.ts index f28dd8941..685dcbba8 100644 --- a/packages/stage-ui-mmd/src/composables/mmd/gaze.ts +++ b/packages/stage-ui-mmd/src/composables/mmd/gaze.ts @@ -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. * diff --git a/packages/stage-ui-mmd/src/composables/mmd/loader.ts b/packages/stage-ui-mmd/src/composables/mmd/loader.ts index 3e1a8def8..7b8fb91b9 100644 --- a/packages/stage-ui-mmd/src/composables/mmd/loader.ts +++ b/packages/stage-ui-mmd/src/composables/mmd/loader.ts @@ -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 { - return new Promise((resolve, reject) => { - loader.load(url, resolve, onProgress, reject) - }) +): Promise { + 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 { - 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) } diff --git a/packages/stage-ui-mmd/src/composables/mmd/morph.ts b/packages/stage-ui-mmd/src/composables/mmd/morph.ts index b7073edc2..3955b4ef9 100644 --- a/packages/stage-ui-mmd/src/composables/mmd/morph.ts +++ b/packages/stage-ui-mmd/src/composables/mmd/morph.ts @@ -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, diff --git a/packages/stage-ui-mmd/src/utils/ammo.ts b/packages/stage-ui-mmd/src/utils/ammo.ts index d38b32960..abc1ed47e 100644 --- a/packages/stage-ui-mmd/src/utils/ammo.ts +++ b/packages/stage-ui-mmd/src/utils/ammo.ts @@ -1,49 +1,19 @@ -import type Ammo from 'ammojs-typed' +import type { PhysicsFactory } from '@moeru/three-mmd' + +let physics: Promise | 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 | undefined - -interface AmmoGlobal { - Ammo?: typeof Ammo -} - -export async function ensureAmmo(): Promise { - 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 { + 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 } diff --git a/packages/stage-ui-mmd/src/utils/mmd-loader.browser.test.ts b/packages/stage-ui-mmd/src/utils/mmd-loader.browser.test.ts new file mode 100644 index 000000000..1962e3942 --- /dev/null +++ b/packages/stage-ui-mmd/src/utils/mmd-loader.browser.test.ts @@ -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> | 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) + } + }) +}) diff --git a/packages/stage-ui-mmd/src/utils/mmd-loader.test.ts b/packages/stage-ui-mmd/src/utils/mmd-loader.test.ts deleted file mode 100644 index d4d9ef238..000000000 --- a/packages/stage-ui-mmd/src/utils/mmd-loader.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/packages/stage-ui-mmd/src/utils/mmd-loader.ts b/packages/stage-ui-mmd/src/utils/mmd-loader.ts index 3a2894442..6aad703fe 100644 --- a/packages/stage-ui-mmd/src/utils/mmd-loader.ts +++ b/packages/stage-ui-mmd/src/utils/mmd-loader.ts @@ -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: () => {}, } diff --git a/packages/stage-ui-mmd/src/utils/mmd-materials.test.ts b/packages/stage-ui-mmd/src/utils/mmd-materials.test.ts new file mode 100644 index 000000000..9de40c01b --- /dev/null +++ b/packages/stage-ui-mmd/src/utils/mmd-materials.test.ts @@ -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) + }) +}) diff --git a/packages/stage-ui-mmd/src/utils/mmd-materials.ts b/packages/stage-ui-mmd/src/utils/mmd-materials.ts new file mode 100644 index 000000000..b4551c2ea --- /dev/null +++ b/packages/stage-ui-mmd/src/utils/mmd-materials.ts @@ -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() + +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): 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() + const materials = new Set() + + 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() +} diff --git a/packages/stage-ui-mmd/src/utils/mmd-preview.ts b/packages/stage-ui-mmd/src/utils/mmd-preview.ts index 7d99df9f9..c41a9a6e4 100644 --- a/packages/stage-ui-mmd/src/utils/mmd-preview.ts +++ b/packages/stage-ui-mmd/src/utils/mmd-preview.ts @@ -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=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: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3415be018..84a275bd2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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