Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: play multiple motions in parallel #164

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,18 @@ window.PIXI = PIXI;
})();
```

### play multiple motions in parallel

```ts
model.parallelMotion([
{group: motion_group1, index: motion_index1, priority: MotionPriority.NORMAL},
{group: motion_group2, index: motion_index2, priority: MotionPriority.NORMAL},
]);
```

If you need to synchronize the playback of expressions and sounds, please use`model.motion`/`model.speak` to play one of the motions, and use `model.parallelMotion` to play the remaining motions. Each item in the motion list has independent priority control based on its index, consistent with the priority logic of `model.motion`.


## Package importing

When importing Pixi packages on-demand, you may need to manually register some plugins to enable optional features.
Expand Down
11 changes: 11 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ window.PIXI = PIXI;
})();
```

### 多动作同步播放

```ts
model.parallelMotion([
{group: motion_group1, index: motion_index1, priority: MotionPriority.NORMAL},
{group: motion_group2, index: motion_index2, priority: MotionPriority.NORMAL},
]);
```

若需要同步播放表情、声音等请使用`model.motion`/`model.speak`播放其中一个动作,其余动作用`model.parallelMotion`播放。列表中按照index每一项都有独立的优先级控制,和`model.motion`逻辑一致。

## 包导入

当按需导入 Pixi 的包时,需要手动注册相应的组件来启用可选功能
Expand Down
27 changes: 27 additions & 0 deletions src/Live2DModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,33 @@ export class Live2DModel<IM extends InternalModel = InternalModel> extends Conta
}
return Promise.resolve(false);
}

/**
* Shorthand to start multiple motions in parallel.
* @param motionList - The motion list: {
* group: The motion group,
* index: Index in the motion group,
* priority - The priority to be applied. (0: No priority, 1: IDLE, 2:NORMAL, 3:FORCE) (default: 2)
* }[]
* @return Promise that resolves with a list, indicates the motion is successfully started, with false otherwise.
*/
async parallelMotion(
motionList: {
group: string,
index: number,
priority?: MotionPriority,
}[]
): Promise<boolean[]> {
this.internalModel.extendParallelMotionManager(motionList.length);
const result = motionList.map((m, idx) => (
this.internalModel.parallelMotionManager[idx]?.startMotion(m.group, m.index, m.priority)
));
let flags = [];
for (let r of result) {
flags.push(await r!);
}
return flags;
}

/**
* Updates the focus position. This will not cause the model to immediately look at the position,
Expand Down
10 changes: 10 additions & 0 deletions src/cubism-common/InternalModel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FocusController } from "@/cubism-common/FocusController";
import type { ModelSettings } from "@/cubism-common/ModelSettings";
import type { MotionManager, MotionManagerOptions } from "@/cubism-common/MotionManager";
import type { ParallelMotionManager } from "@/cubism-common/ParallelMotionManager";
import { LOGICAL_HEIGHT, LOGICAL_WIDTH } from "@/cubism-common/constants";
import { Matrix, utils } from "@pixi/core";
import type { Mutable } from "../types/helpers";
Expand Down Expand Up @@ -56,6 +57,7 @@ export abstract class InternalModel extends utils.EventEmitter {
focusController = new FocusController();

abstract motionManager: MotionManager;
abstract parallelMotionManager: ParallelMotionManager[];

pose?: any;
physics?: any;
Expand Down Expand Up @@ -270,6 +272,8 @@ export abstract class InternalModel extends utils.EventEmitter {

this.motionManager.destroy();
(this as Partial<this>).motionManager = undefined;
this.parallelMotionManager.forEach(m => m.destroy());
this.parallelMotionManager = [];
}

/**
Expand Down Expand Up @@ -326,4 +330,10 @@ export abstract class InternalModel extends utils.EventEmitter {
* Draws the model.
*/
abstract draw(gl: WebGLRenderingContext): void;

/**
* Add parallel motion manager.
* @param managerCount - Count of parallel motion managers.
*/
abstract extendParallelMotionManager(managerCount: number): void;
}
206 changes: 206 additions & 0 deletions src/cubism-common/ParallelMotionManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import type { MotionManager } from "@/cubism-common/MotionManager";
import type { ModelSettings } from "@/cubism-common/ModelSettings";
import { MotionPriority, MotionState } from "@/cubism-common/MotionState";
import { logger } from "@/utils";
import { utils } from "@pixi/core";
import type { Mutable } from "../types/helpers";




/**
* Handles the motion playback.
* @emits {@link MotionManagerEvents}
*/
export abstract class ParallelMotionManager<Motion = any, MotionSpec = any> extends utils.EventEmitter {
/**
* Tag for logging.
*/
tag: string;

manager: MotionManager;


/**
* The ModelSettings reference.
*/
readonly settings: ModelSettings;


/**
* Maintains the state of this MotionManager.
*/
state = new MotionState();


/**
* Flags there's a motion playing.
*/
playing = false;

/**
* Flags the instances has been destroyed.
*/
destroyed = false;

protected constructor(settings: ModelSettings, manager: MotionManager) {
super();
this.settings = settings;
this.tag = `ParallelMotionManager(${settings.name})`;
this.state.tag = this.tag;
this.manager = manager;
}

/**
* Starts a motion as given priority.
* @param group - The motion group.
* @param index - Index in the motion group.
* @param priority - The priority to be applied. default: 2 (NORMAL)
* ### OPTIONAL: {name: value, ...}
* @param sound - The audio url to file or base64 content
* @param volume - Volume of the sound (0-1)
* @param expression - In case you want to mix up a expression while playing sound (bind with Model.expression())
* @param resetExpression - Reset expression before and after playing sound (default: true)
* @param crossOrigin - Cross origin setting.
* @return Promise that resolves with true if the motion is successfully started, with false otherwise.
*/
async startMotion(
group: string,
index: number,
priority: MotionPriority = MotionPriority.NORMAL,
): Promise<boolean> {
if (!this.state.reserve(group, index, priority)) {
return false;
}


const definition = this.manager.definitions[group]?.[index];
if (!definition) {
return false;
}

const motion = await this.manager.loadMotion(group, index);


if (!this.state.start(motion, group, index, priority)) {
return false;
}
logger.log(this.tag, "Start motion:", this.getMotionName(definition));

this.emit("motionStart", group, index, undefined);

this.playing = true;

this._startMotion(motion!);

return true;
}

/**
* Starts a random Motion as given priority.
* @param group - The motion group.
* @param priority - The priority to be applied. (default: 1 `IDLE`)
* ### OPTIONAL: {name: value, ...}
* @param sound - The wav url file or base64 content+
* @param volume - Volume of the sound (0-1) (default: 1)
* @param expression - In case you want to mix up a expression while playing sound (name/index)
* @param resetExpression - Reset expression before and after playing sound (default: true)
* @return Promise that resolves with true if the motion is successfully started, with false otherwise.
*/
async startRandomMotion(
group: string,
priority?: MotionPriority
): Promise<boolean> {
const groupDefs = this.manager.definitions[group];

if (groupDefs?.length) {
const availableIndices: number[] = [];

for (let i = 0; i < groupDefs!.length; i++) {
if (this.manager.motionGroups[group]![i] !== null && !this.state.isActive(group, i)) {
availableIndices.push(i);
}
}

if (availableIndices.length) {
const index =
availableIndices[Math.floor(Math.random() * availableIndices.length)]!;

return this.startMotion(group, index, priority);
}
}

return false;
}

/**
* Stops all playing motions as well as the sound.
*/
stopAllMotions(): void {
this._stopAllMotions();

this.state.reset();

}

/**
* Updates parameters of the core model.
* @param model - The core model.
* @param now - Current time in milliseconds.
* @return True if the parameters have been actually updated.
*/
update(model: object, now: DOMHighResTimeStamp): boolean {
if (this.isFinished()) {
if (this.playing) {
this.playing = false;
this.emit("motionFinish");
}

this.state.complete();
}
return this.updateParameters(model, now);
}


/**
* Destroys the instance.
* @emits {@link MotionManagerEvents.destroy}
*/
destroy() {
this.destroyed = true;
this.emit("destroy");

this.stopAllMotions();

const self = this as Mutable<Partial<this>>;
}

/**
* Checks if the motion playback has finished.
*/
abstract isFinished(): boolean;

/**
* Retrieves the motion's name by its definition.
* @return The motion's name.
*/
protected abstract getMotionName(definition: MotionSpec): string;

/**
* Starts the Motion.
*/
protected abstract _startMotion(motion: Motion, onFinish?: (motion: Motion) => void): number;

/**
* Stops all playing motions.
*/
protected abstract _stopAllMotions(): void;

/**
* Updates parameters of the core model.
* @param model - The core model.
* @param now - Current time in milliseconds.
* @return True if the parameters have been actually updated.
*/
protected abstract updateParameters(model: object, now: DOMHighResTimeStamp): boolean;
}
13 changes: 12 additions & 1 deletion src/cubism2/Cubism2InternalModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { InternalModel } from "@/cubism-common/InternalModel";
import { logger } from "../utils";
import type { Cubism2ModelSettings } from "./Cubism2ModelSettings";
import { Cubism2MotionManager } from "./Cubism2MotionManager";
import { Cubism2ParallelMotionManager } from "./Cubism2ParallelMotionManager";
import { Live2DEyeBlink } from "./Live2DEyeBlink";
import type { Live2DPhysics } from "./Live2DPhysics";
import type { Live2DPose } from "./Live2DPose";
Expand All @@ -21,6 +22,7 @@ export class Cubism2InternalModel extends InternalModel {

coreModel: Live2DModelWebGL;
motionManager: Cubism2MotionManager;
parallelMotionManager: Cubism2ParallelMotionManager[];

eyeBlink?: Live2DEyeBlink;

Expand Down Expand Up @@ -61,6 +63,7 @@ export class Cubism2InternalModel extends InternalModel {
this.coreModel = coreModel;
this.settings = settings;
this.motionManager = new Cubism2MotionManager(settings, options);
this.parallelMotionManager = [];
this.eyeBlink = new Live2DEyeBlink(coreModel);

this.eyeballXParamIndex = coreModel.getParamIndex("PARAM_EYE_BALL_X");
Expand Down Expand Up @@ -226,7 +229,9 @@ export class Cubism2InternalModel extends InternalModel {

this.emit("beforeMotionUpdate");

const motionUpdated = this.motionManager.update(this.coreModel, now);
const motionUpdated0 = this.motionManager.update(model, now);
const parallelMotionUpdated = this.parallelMotionManager.map(m => m.update(model, now));
const motionUpdated = motionUpdated0 || parallelMotionUpdated.reduce((prev, curr) => prev || curr, false);

this.emit("afterMotionUpdate");

Expand Down Expand Up @@ -297,6 +302,12 @@ export class Cubism2InternalModel extends InternalModel {
this.hasDrawn = true;
this.disableCulling = disableCulling;
}

extendParallelMotionManager(managerCount: number) {
while (this.parallelMotionManager.length < managerCount) {
this.parallelMotionManager.push(new Cubism2ParallelMotionManager(this.settings, this.motionManager))
}
}

destroy() {
super.destroy();
Expand Down
Loading