@@ -26,6 +26,21 @@ export type PoseLayerId = 'idle_layer' | 'speech_layer' | 'backend_pose_layer' |
2626interface LayerState {
2727 weight : number ;
2828 values : PoseValues ;
29+ weightTransition : WeightTransition | null ;
30+ }
31+
32+ interface WeightTransition {
33+ fromWeight : number ;
34+ toWeight : number ;
35+ startTimeMs : number ;
36+ durationMs : number ;
37+ }
38+
39+ interface ScalarTransition {
40+ fromValue : number ;
41+ toValue : number ;
42+ startTimeMs : number ;
43+ durationMs : number ;
2944}
3045
3146const DEFAULT_LAYER_WEIGHTS : Record < PoseLayerId , number > = {
@@ -46,10 +61,26 @@ const ORIENTATION_CHANNELS: LogicalChannel[] = [
4661 'gaze_y' ,
4762] ;
4863
64+ const LAYER_WEIGHT_TRANSITION_MS = 480 ;
65+ const IDLE_MOUTH_BLEND_TRANSITION_MS = 320 ;
66+
4967function clamp ( value : number , min : number , max : number ) : number {
5068 return Math . min ( Math . max ( value , min ) , max ) ;
5169}
5270
71+ function easeInOutCubic ( alpha : number ) : number {
72+ if ( alpha <= 0 ) {
73+ return 0 ;
74+ }
75+ if ( alpha >= 1 ) {
76+ return 1 ;
77+ }
78+ if ( alpha < 0.5 ) {
79+ return 4 * alpha * alpha * alpha ;
80+ }
81+ return 1 - ( ( - 2 * alpha + 2 ) ** 3 ) / 2 ;
82+ }
83+
5384function sanitizeChannelValue ( channel : LogicalChannel , value : number ) : number | null {
5485 if ( ! Number . isFinite ( value ) ) {
5586 return null ;
@@ -83,11 +114,15 @@ export class Live2DPoseMixerController {
83114
84115 private idleMouthEnabled = true ;
85116
117+ private idleMouthBlend = 1 ;
118+
119+ private idleMouthBlendTransition : ScalarTransition | null = null ;
120+
86121 private layers : Record < PoseLayerId , LayerState > = {
87- idle_layer : { weight : DEFAULT_LAYER_WEIGHTS . idle_layer , values : { } } ,
88- speech_layer : { weight : DEFAULT_LAYER_WEIGHTS . speech_layer , values : { } } ,
89- backend_pose_layer : { weight : DEFAULT_LAYER_WEIGHTS . backend_pose_layer , values : { } } ,
90- mouse_attention_layer : { weight : DEFAULT_LAYER_WEIGHTS . mouse_attention_layer , values : { } } ,
122+ idle_layer : { weight : DEFAULT_LAYER_WEIGHTS . idle_layer , values : { } , weightTransition : null } ,
123+ speech_layer : { weight : DEFAULT_LAYER_WEIGHTS . speech_layer , values : { } , weightTransition : null } ,
124+ backend_pose_layer : { weight : DEFAULT_LAYER_WEIGHTS . backend_pose_layer , values : { } , weightTransition : null } ,
125+ mouse_attention_layer : { weight : DEFAULT_LAYER_WEIGHTS . mouse_attention_layer , values : { } , weightTransition : null } ,
91126 } ;
92127
93128 private readonly recordedIdleDriver = new RecordedIdleDriver ( ( pose ) => {
@@ -97,8 +132,9 @@ export class Live2DPoseMixerController {
97132 private lastFinalPose : PoseValues = { } ;
98133
99134 private getMouseAttentionDragInput ( ) : { x : number ; y : number } {
135+ const nowMs = performance . now ( ) ;
100136 const layer = this . layers . mouse_attention_layer ;
101- const layerWeight = layer ?. weight ;
137+ const layerWeight = this . getResolvedLayerWeight ( 'mouse_attention_layer' , nowMs ) ;
102138 if ( ! layer || typeof layerWeight !== 'number' || ! Number . isFinite ( layerWeight ) || layerWeight <= 0 ) {
103139 return { x : 0 , y : 0 } ;
104140 }
@@ -123,9 +159,14 @@ export class Live2DPoseMixerController {
123159 */
124160 public setLayerPose ( layerId : PoseLayerId , values : PoseValues , weight ?: number ) : void {
125161 this . layers [ layerId ] = {
126- weight : typeof weight === 'number' && Number . isFinite ( weight ) ? weight : this . layers [ layerId ] . weight ,
162+ weight : this . layers [ layerId ] . weight ,
127163 values : { ...values } ,
164+ weightTransition : this . layers [ layerId ] . weightTransition ,
128165 } ;
166+ if ( typeof weight === 'number' && Number . isFinite ( weight ) ) {
167+ this . transitionLayerWeight ( layerId , weight ) ;
168+ return ;
169+ }
129170 this . refreshFinalPoseSnapshot ( ) ;
130171 }
131172
@@ -134,11 +175,15 @@ export class Live2DPoseMixerController {
134175 * Useful for backends that stream only changed channels.
135176 */
136177 public patchLayerPose ( layerId : PoseLayerId , values : PoseValues , weight ?: number ) : void {
137- const nextWeight = typeof weight === 'number' && Number . isFinite ( weight ) ? weight : this . layers [ layerId ] . weight ;
138178 this . layers [ layerId ] = {
139- weight : nextWeight ,
179+ weight : this . layers [ layerId ] . weight ,
140180 values : { ...this . layers [ layerId ] . values , ...values } ,
181+ weightTransition : this . layers [ layerId ] . weightTransition ,
141182 } ;
183+ if ( typeof weight === 'number' && Number . isFinite ( weight ) ) {
184+ this . transitionLayerWeight ( layerId , weight ) ;
185+ return ;
186+ }
142187 this . refreshFinalPoseSnapshot ( ) ;
143188 }
144189
@@ -174,11 +219,7 @@ export class Live2DPoseMixerController {
174219 if ( ! Number . isFinite ( weight ) || weight < 0 ) {
175220 return ;
176221 }
177- this . layers [ layerId ] = {
178- ...this . layers [ layerId ] ,
179- weight,
180- } ;
181- this . refreshFinalPoseSnapshot ( ) ;
222+ this . transitionLayerWeight ( layerId , weight ) ;
182223 }
183224
184225 public patchLayerWeights ( weights : Partial < Record < PoseLayerId , number > > ) : void {
@@ -188,10 +229,7 @@ export class Live2DPoseMixerController {
188229 if ( typeof weight !== 'number' || ! Number . isFinite ( weight ) || weight < 0 ) {
189230 return ;
190231 }
191- this . layers [ layerId ] = {
192- ...this . layers [ layerId ] ,
193- weight,
194- } ;
232+ this . transitionLayerWeight ( layerId , weight , false ) ;
195233 changed = true ;
196234 } ) ;
197235
@@ -202,10 +240,7 @@ export class Live2DPoseMixerController {
202240
203241 public resetLayerWeights ( ) : void {
204242 ( Object . keys ( DEFAULT_LAYER_WEIGHTS ) as PoseLayerId [ ] ) . forEach ( ( layerId ) => {
205- this . layers [ layerId ] = {
206- ...this . layers [ layerId ] ,
207- weight : DEFAULT_LAYER_WEIGHTS [ layerId ] ,
208- } ;
243+ this . transitionLayerWeight ( layerId , DEFAULT_LAYER_WEIGHTS [ layerId ] , false ) ;
209244 } ) ;
210245 this . refreshFinalPoseSnapshot ( ) ;
211246 }
@@ -245,8 +280,11 @@ export class Live2DPoseMixerController {
245280 const shouldEnableIdleMouth = this . idleState !== 'speaking' ;
246281 if ( this . idleMouthEnabled !== shouldEnableIdleMouth ) {
247282 this . idleMouthEnabled = shouldEnableIdleMouth ;
248- this . refreshFinalPoseSnapshot ( ) ;
283+ this . transitionIdleMouthBlend ( shouldEnableIdleMouth ? 1 : 0 ) ;
284+ return ;
249285 }
286+
287+ this . refreshFinalPoseSnapshot ( ) ;
250288 }
251289
252290 public setRecordedIdleBank ( bank : IdleBankConfig | null ) : void {
@@ -270,22 +308,35 @@ export class Live2DPoseMixerController {
270308 }
271309
272310 public getLayerStates ( ) : Record < PoseLayerId , LayerState > {
311+ const nowMs = performance . now ( ) ;
273312 return {
274313 idle_layer : {
275- weight : this . layers . idle_layer . weight ,
314+ weight : this . getResolvedLayerWeight ( ' idle_layer' , nowMs ) ,
276315 values : { ...this . layers . idle_layer . values } ,
316+ weightTransition : this . layers . idle_layer . weightTransition
317+ ? { ...this . layers . idle_layer . weightTransition }
318+ : null ,
277319 } ,
278320 speech_layer : {
279- weight : this . layers . speech_layer . weight ,
321+ weight : this . getResolvedLayerWeight ( ' speech_layer' , nowMs ) ,
280322 values : { ...this . layers . speech_layer . values } ,
323+ weightTransition : this . layers . speech_layer . weightTransition
324+ ? { ...this . layers . speech_layer . weightTransition }
325+ : null ,
281326 } ,
282327 backend_pose_layer : {
283- weight : this . layers . backend_pose_layer . weight ,
328+ weight : this . getResolvedLayerWeight ( ' backend_pose_layer' , nowMs ) ,
284329 values : { ...this . layers . backend_pose_layer . values } ,
330+ weightTransition : this . layers . backend_pose_layer . weightTransition
331+ ? { ...this . layers . backend_pose_layer . weightTransition }
332+ : null ,
285333 } ,
286334 mouse_attention_layer : {
287- weight : this . layers . mouse_attention_layer . weight ,
335+ weight : this . getResolvedLayerWeight ( ' mouse_attention_layer' , nowMs ) ,
288336 values : { ...this . layers . mouse_attention_layer . values } ,
337+ weightTransition : this . layers . mouse_attention_layer . weightTransition
338+ ? { ...this . layers . mouse_attention_layer . weightTransition }
339+ : null ,
289340 } ,
290341 } ;
291342 }
@@ -299,6 +350,7 @@ export class Live2DPoseMixerController {
299350 modelUrl : this . modelUrl ?? null ,
300351 idleState : this . idleState ,
301352 idleMouthEnabled : this . idleMouthEnabled ,
353+ idleMouthBlend : this . getIdleMouthBlend ( performance . now ( ) ) ,
302354 layers : this . getLayerStates ( ) ,
303355 finalPose : this . getFinalMixedPose ( ) ,
304356 profile : this . getProfile ( ) ,
@@ -393,30 +445,133 @@ export class Live2DPoseMixerController {
393445 w . Live2DPoseMixerDebug = debugApi ;
394446 }
395447
396- private getActiveLayers ( ) : PoseLayer [ ] {
448+ private transitionLayerWeight ( layerId : PoseLayerId , targetWeight : number , refreshSnapshot : boolean = true ) : void {
449+ const nowMs = performance . now ( ) ;
450+ const currentWeight = this . getResolvedLayerWeight ( layerId , nowMs ) ;
451+ const normalizedTarget = Math . max ( 0 , targetWeight ) ;
452+ this . layers [ layerId ] = {
453+ ...this . layers [ layerId ] ,
454+ weight : normalizedTarget ,
455+ weightTransition : Math . abs ( currentWeight - normalizedTarget ) <= 1e-4
456+ ? null
457+ : {
458+ fromWeight : currentWeight ,
459+ toWeight : normalizedTarget ,
460+ startTimeMs : nowMs ,
461+ durationMs : LAYER_WEIGHT_TRANSITION_MS ,
462+ } ,
463+ } ;
464+
465+ if ( refreshSnapshot ) {
466+ this . refreshFinalPoseSnapshot ( ) ;
467+ }
468+ }
469+
470+ private transitionIdleMouthBlend ( targetValue : number ) : void {
471+ const nowMs = performance . now ( ) ;
472+ const currentValue = this . getIdleMouthBlend ( nowMs ) ;
473+ const normalizedTarget = clamp ( targetValue , 0 , 1 ) ;
474+ this . idleMouthBlend = normalizedTarget ;
475+ this . idleMouthBlendTransition = Math . abs ( currentValue - normalizedTarget ) <= 1e-4
476+ ? null
477+ : {
478+ fromValue : currentValue ,
479+ toValue : normalizedTarget ,
480+ startTimeMs : nowMs ,
481+ durationMs : IDLE_MOUTH_BLEND_TRANSITION_MS ,
482+ } ;
483+ this . refreshFinalPoseSnapshot ( ) ;
484+ }
485+
486+ private getResolvedTransitionValue (
487+ fromValue : number ,
488+ toValue : number ,
489+ startTimeMs : number ,
490+ durationMs : number ,
491+ nowMs : number ,
492+ ) : number {
493+ if ( durationMs <= 0 ) {
494+ return toValue ;
495+ }
496+
497+ const progress = clamp ( ( nowMs - startTimeMs ) / durationMs , 0 , 1 ) ;
498+ const alpha = easeInOutCubic ( progress ) ;
499+ return fromValue + ( toValue - fromValue ) * alpha ;
500+ }
501+
502+ private getResolvedLayerWeight ( layerId : PoseLayerId , nowMs : number = performance . now ( ) ) : number {
503+ const layer = this . layers [ layerId ] ;
504+ const transition = layer . weightTransition ;
505+ if ( ! transition ) {
506+ return layer . weight ;
507+ }
508+
509+ const resolvedWeight = this . getResolvedTransitionValue (
510+ transition . fromWeight ,
511+ transition . toWeight ,
512+ transition . startTimeMs ,
513+ transition . durationMs ,
514+ nowMs ,
515+ ) ;
516+
517+ if ( nowMs - transition . startTimeMs >= transition . durationMs ) {
518+ this . layers [ layerId ] = {
519+ ...layer ,
520+ weightTransition : null ,
521+ } ;
522+ return transition . toWeight ;
523+ }
524+
525+ return resolvedWeight ;
526+ }
527+
528+ private getIdleMouthBlend ( nowMs : number = performance . now ( ) ) : number {
529+ const transition = this . idleMouthBlendTransition ;
530+ if ( ! transition ) {
531+ return this . idleMouthBlend ;
532+ }
533+
534+ const resolvedValue = this . getResolvedTransitionValue (
535+ transition . fromValue ,
536+ transition . toValue ,
537+ transition . startTimeMs ,
538+ transition . durationMs ,
539+ nowMs ,
540+ ) ;
541+
542+ if ( nowMs - transition . startTimeMs >= transition . durationMs ) {
543+ this . idleMouthBlendTransition = null ;
544+ return transition . toValue ;
545+ }
546+
547+ return resolvedValue ;
548+ }
549+
550+ private getActiveLayers ( nowMs : number = performance . now ( ) ) : PoseLayer [ ] {
551+ const idleMouthBlend = this . getIdleMouthBlend ( nowMs ) ;
397552 const layers : PoseLayer [ ] = [
398553 {
399554 id : 'idle_layer' ,
400- weight : this . layers . idle_layer . weight ,
555+ weight : this . getResolvedLayerWeight ( ' idle_layer' , nowMs ) ,
401556 frame : isEmptyPose ( this . layers . idle_layer . values ) ? null : { values : this . layers . idle_layer . values } ,
402557 // 运行时策略:
403558 // - listening: 允许 recorded idle 的 mouth_open 参与
404559 // - speaking: 屏蔽 idle 的 mouth_open,嘴部交给语音链路(lip sync / speech)
405- mask : this . idleMouthEnabled ? undefined : { mouth_open : 0 } ,
560+ mask : idleMouthBlend >= 0.999 ? undefined : { mouth_open : idleMouthBlend } ,
406561 } ,
407562 {
408563 id : 'speech_layer' ,
409- weight : this . layers . speech_layer . weight ,
564+ weight : this . getResolvedLayerWeight ( ' speech_layer' , nowMs ) ,
410565 frame : isEmptyPose ( this . layers . speech_layer . values ) ? null : { values : this . layers . speech_layer . values } ,
411566 } ,
412567 {
413568 id : 'backend_pose_layer' ,
414- weight : this . layers . backend_pose_layer . weight ,
569+ weight : this . getResolvedLayerWeight ( ' backend_pose_layer' , nowMs ) ,
415570 frame : isEmptyPose ( this . layers . backend_pose_layer . values ) ? null : { values : this . layers . backend_pose_layer . values } ,
416571 } ,
417572 {
418573 id : 'mouse_attention_layer' ,
419- weight : this . layers . mouse_attention_layer . weight ,
574+ weight : this . getResolvedLayerWeight ( ' mouse_attention_layer' , nowMs ) ,
420575 frame : isEmptyPose ( this . layers . mouse_attention_layer . values ) ? null : { values : this . layers . mouse_attention_layer . values } ,
421576 } ,
422577 ] ;
@@ -425,7 +580,7 @@ export class Live2DPoseMixerController {
425580 }
426581
427582 private refreshFinalPoseSnapshot ( ) : void {
428- this . lastFinalPose = this . mixer . apply ( this . getActiveLayers ( ) ) ;
583+ this . lastFinalPose = this . mixer . apply ( this . getActiveLayers ( performance . now ( ) ) ) ;
429584 }
430585
431586 private applyParameterByMode (
@@ -453,10 +608,11 @@ export class Live2DPoseMixerController {
453608 this . refreshFinalPoseSnapshot ( ) ;
454609 const finalPose : PoseValues = { ...this . lastFinalPose } ;
455610
456- const isMouseOnlyMode = this . layers . mouse_attention_layer . weight > 0
457- && this . layers . idle_layer . weight <= 0
458- && this . layers . speech_layer . weight <= 0
459- && this . layers . backend_pose_layer . weight <= 0 ;
611+ const nowMs = performance . now ( ) ;
612+ const isMouseOnlyMode = this . getResolvedLayerWeight ( 'mouse_attention_layer' , nowMs ) > 0
613+ && this . getResolvedLayerWeight ( 'idle_layer' , nowMs ) <= 0
614+ && this . getResolvedLayerWeight ( 'speech_layer' , nowMs ) <= 0
615+ && this . getResolvedLayerWeight ( 'backend_pose_layer' , nowMs ) <= 0 ;
460616
461617 if ( isMouseOnlyMode ) {
462618 ORIENTATION_CHANNELS . forEach ( ( channel ) => {
0 commit comments