## Description
The VRM and MMD renderers had the same two boundary problems:
- Both imported the Stage audio store from the `stage-ui` source tree.
- Both kept copies of the wLipSync profile and model-neutral vowel
policy.
This PR solves both problems as one architecture change.
```text
Stage owns AudioContext and the current audio source
-> ThreeScene / MMDScene receive both as inputs
-> model-driver-lipsync owns the profile and shared vowel policy
-> VRM maps AEIOU to aa / ee / ih / oh / ou
-> MMD maps AEIOU to vowelA / vowelE / vowelI / vowelO / vowelU
```
The shared driver owns viseme projection, winner and runner selection,
silence handling, and smoothing. It has no Vue, Three.js, VRM, or MMD
dependency. Each renderer keeps only its model-specific mapping.
The renderer packages no longer import the Stage audio store or depend
directly on `wlipsync`. Stage passes the owning `AudioContext` down with
the current audio source. Cleanup disconnects only the wLipSync node
owned by that renderer.
## Current solution
The package-boundary review identified two related findings:
- Finding 3: The VRM and MMD renderers imported `stage-ui` audio state
through source paths.
- Finding 4: The same renderers duplicated the wLipSync profile and
model-neutral vowel policy.
Both findings came from one missing boundary between Stage, the lip-sync
driver, and the renderer adapters. This PR fixes that boundary with one
ownership change.
| Layer | Responsibility |
| --- | --- |
| `stage-ui` | Owns the `AudioContext` and the current audio source. It
passes both values to each renderer. |
| `stage-ui-three` and `stage-ui-mmd` | Connect renderer components to
the shared driver. They do not import the Stage audio store. |
| `model-driver-lipsync/runtime/wlipsync` | Exposes `createWLipSyncNode`
as the public runtime entry. |
| `model-driver-lipsync/shared/wlipsync` | Owns the profile, public
types, and model-neutral vowel policy. |
Runtime flow:
1. `Stage.vue` passes `audioContext` and `currentAudioSource` to
`ThreeScene` or `MMDScene`.
2. The renderer composable watches the context and creates one wLipSync
node for that context.
3. The source watcher connects the current audio source to that node.
4. Each render frame sends the wLipSync values to
`createWLipSyncVowelDriver`.
5. The renderer adapter applies the returned AEIOU weights to its model
controls.
6. Cleanup disconnects the exact source and node pair that the renderer
owns.
The shared policy keeps the current renderer behavior:
- It maps the raw `S` viseme to `I`.
- It selects the two strongest vowels.
- It uses the existing volume and silence thresholds.
- It keeps the 160 ms silence window.
- It keeps the existing attack, release, cap, and output scale values.
The renderer-specific mappings stay in their renderer packages:
- VRM maps AEIOU to `aa`, `ee`, `ih`, `oh`, and `ou`.
- MMD maps AEIOU to `vowelA`, `vowelE`, `vowelI`, `vowelO`, and
`vowelU`.
This solution removes both duplicate profile files. It also removes the
direct `wlipsync` dependencies from both renderer packages.
## Linked Issues
None.
## Additional Context
There is no intended visual change. This refactor preserves the existing
vowel weights and renderer mappings.
Verification:
- `sem diff --staged --no-cosmetics -v --file-exts .ts .tsx`
- `sem diff --staged --no-cosmetics -v --file-exts .vue`
- `sem impact --entity-id <useVRMLipSync> --json --dependents`
- `sem impact --entity-id <useMMDLipSync> --json --dependents`
- `pnpm -F @proj-airi/model-driver-lipsync test` — 3 tests passed
- `pnpm -F @proj-airi/stage-ui-three exec vitest run` — 18 tests passed
- `pnpm -F @proj-airi/stage-ui-mmd exec vitest run` — 23 tests passed
- Type checks passed for `model-driver-lipsync`, `stage-ui-three`,
`stage-ui-mmd`, and `stage-ui`
- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm lint` — 0 errors and 7 existing warnings outside this change
- `git diff --check`
The full workspace `pnpm typecheck` was also run. In the isolated
worktree it stopped in unchanged `electron-vueuse` and
`plugin-sdk-tamagotchi` because their generated dependency outputs were
not present. All affected package type checks above passed.
@proj-airi/stage-ui-three
Three.js runtime components, stores, composables, and diagnostics used by AIRI stage surfaces.
What It Does
- Hosts the shared Three scene root used by stage pages.
- Loads, mounts, reuses, and disposes VRM models.
- Exposes a package-local model store for camera, light, environment, and model view state.
- Provides Three-specific hit testing, render-target helpers, and VRM preview generation.
- Exposes a local
tracesubmodule for Three/VRM runtime diagnostics.
Exports
ThreeScene: the main Three-backed stage component.useModelStore: Pinia store for Three scene and model configuration.@proj-airi/stage-ui-three/trace: local Eventa trace bus and snapshot helpers.@proj-airi/stage-ui-three/composables/vrm: VRM loading and animation helpers.@proj-airi/stage-ui-three/utils/vrm-preview: one-off VRM preview rendering.composables/hit-testandcomposables/render-target: renderer readback helpers.
VRM Lifecycle
VRM runtime management is explicit in this package.
VRMModel.vuedrives load, commit, and cleanup separately instead of relying on component remounts as the primary lifecycle.- Detached VRM instances are cached through
components/Model/vrm-instance-cache.tsasManagedVrmInstancevalues. - Cache entries are scoped by
scopeKeyand matched bymodelSrcbefore reuse. - Component unmount may stash the current instance for reuse.
- Model switch clears the active instance and any detached cache for the current scope.
- Fresh loads prepare the next VRM off-screen, then commit it into the active scene once ready.
This keeps ordinary remounts and HMR from immediately forcing deep VRM disposal, while still making model switches deterministic.
Lip-Sync Boundary
ThreeScene receives the AudioContext and current audio source from its caller. The package does not import the Stage audio store.
The shared driver produces AEIOU weights. The VRM adapter maps these weights to aa, ee, ih, oh, and ou expressions.
Scene Lifecycle
ThreeScene coordinates two independent async readiness signals before the scene becomes interactive:
- VRM load:
VRMModelemitssceneBootstrap(geometry data) thenloadedonce the model is committed into the scene. - Controls init:
OrbitControlsemitsorbitControlsReadyonce it has obtained the camera and renderer references.
These two signals are coordinated through a binding transaction tracked in useModelStore:
scenePhase: the current phase of the scene —pending→loading→binding→mounted(orerror/no-model).sceneTransactionDepth: incremented at the start of a load or rebind cycle, decremented when binding completes. The scene is considered mid-flight whenever depth > 0.sceneMutationLocked: computed fromscenePhaseandsceneTransactionDepth. True whenever the scene is not yet mounted or a transaction is in progress. Used to gate user interactions like orbit controls.
When either signal arrives, ThreeScene calls completeSceneBinding(). That function:
- Sets
scenePhaseto'binding'immediately to block prematuremountedresolution. - Applies the pending
SceneBootstrappayload (camera position, model origin, eye height, etc.) if one exists. - Calls
controlsRef.update()after anextTickso OrbitControls reads the updated camera state. - Resolves
scenePhaseto its final value based on currentmodelPhaseandcanvasReady.
Whichever signal arrives second completes the binding. The first signal to arrive finds the other not yet ready and resolves back to 'loading'.
Camera Preservation on Model Switch
SceneBootstrap carries a cacheHit flag. ThreeScene.applySceneBootstrap uses the transaction reason to decide how to apply bootstrap data:
initial-load/unknown: camera position and target are reset to the model's computed defaults.model-switch: the user's existing camera offset and look-at angle relative to the previous model origin are preserved and projected onto the new model origin.
Subtree Watch
ThreeScene watches modelRef and controlsRef with flush: 'sync' to detect immediate detach events within the same tick. When controlsRef goes null (e.g. TresJS internal remount), controlsReady resets and a new binding transaction opens. When modelRef goes null, scenePhase reverts to loading without opening a new transaction, since the next loadStart event from VRMModel will open one.
trace Submodule
@proj-airi/stage-ui-three/trace provides:
- A local Eventa context for Three/VRM runtime trace events.
- Ref-counted enable/disable controls so hot paths can short-circuit when tracing is off.
- Shared event definitions for:
- Three render info
- Three hit-test readback
- VRM update frame breakdown
- VRM load start / end / error
- VRM dispose start / end
- Resource snapshot helpers for renderer memory and VRM scene summaries.
The trace bus is intentionally local to stage-ui-three. Desktop apps can bridge it across renderer windows when needed, but the source of truth stays in this package.
When To Use It
- Use it when a stage surface needs a Three-backed renderer.
- Use
useModelStorewhen the page needs to control camera, lighting, model transforms, or renderer-facing state. - Use
@proj-airi/stage-ui-three/tracewhen you need Three/VRM runtime telemetry without routing through Vue parent chains. - Use
utils/vrm-previewfor isolated preview rendering that should not participate in the main stage lifecycle.
When Not To Use It
- Do not use it as a global business event bus.
- Do not use the
tracesubmodule for Live2D or non-Three runtime telemetry. - Do not route renderer-to-main control flow through the
tracesubmodule; keep control IPC in app-level contracts. - Do not use the VRM instance cache as a general shared asset cache across apps or windows.