mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
refactor(stage-ui-mmd): use @moeru/three-mmd (#2167)
This commit is contained in:
@@ -277,6 +277,7 @@ words:
|
||||
- safetensors
|
||||
- SAVEPOINT
|
||||
- Screenable
|
||||
- SDEF
|
||||
- sdkmanager
|
||||
- sensenova
|
||||
- serde
|
||||
@@ -315,6 +316,7 @@ words:
|
||||
- tinyexec
|
||||
- togetherapi
|
||||
- tolist
|
||||
- toon
|
||||
- tresjs
|
||||
- Triggerable
|
||||
- truncatable
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
Generated
+40
-8
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user