@doki-land/live2d 0.0.23 → 0.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/index.d.ts +67 -11
- package/dist/index.js +169 -32
- package/package.json +4 -4
- package/src/facade/create-live2d.ts +5 -5
- package/src/index.ts +14 -6
- package/src/physics/apply-physics3.ts +26 -0
- package/src/physics/index.ts +7 -0
- package/src/physics/parse-physics3.ts +52 -0
- package/src/physics/types.ts +20 -0
- package/src/stage/actor-model-slot.ts +58 -8
- package/src/stage/actor.ts +28 -1
- package/src/stage/index.ts +2 -2
- package/src/stage/model-asset-registry.ts +49 -15
- package/src/stage/single-facade.ts +12 -7
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ The implementation packages are installed transitively. Most applications should
|
|
|
28
28
|
## 🧭 Source Layout
|
|
29
29
|
|
|
30
30
|
```text
|
|
31
|
-
src/facade/
|
|
31
|
+
src/facade/ createLive2d() — default stage + single actor
|
|
32
32
|
src/motion/ motion3 parse, curves, MotionPlayer
|
|
33
33
|
src/stage/ Live2dStage, actors, asset registry, transforms
|
|
34
34
|
src/reexports/ optional subpath exports for core / loader / renderer
|
|
@@ -37,7 +37,7 @@ src/reexports/ optional subpath exports for core / loader / renderer
|
|
|
37
37
|
## 🚀 Quick Start
|
|
38
38
|
|
|
39
39
|
```ts
|
|
40
|
-
import {
|
|
40
|
+
import {createLive2d} from "@doki-land/live2d";
|
|
41
41
|
|
|
42
42
|
const canvas = document.querySelector<HTMLCanvasElement>("#live2d");
|
|
43
43
|
|
|
@@ -45,7 +45,7 @@ if (!canvas) {
|
|
|
45
45
|
throw new Error("Missing Live2D canvas");
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const runtime =
|
|
48
|
+
const runtime = createLive2d({
|
|
49
49
|
prefer: ["webgpu", "webgl2", "canvas2d"],
|
|
50
50
|
});
|
|
51
51
|
|
|
@@ -159,7 +159,7 @@ scheduling costs.
|
|
|
159
159
|
Advanced applications can provide a renderer or model backends:
|
|
160
160
|
|
|
161
161
|
```ts
|
|
162
|
-
const runtime =
|
|
162
|
+
const runtime = createLive2d({
|
|
163
163
|
renderer: customRenderer,
|
|
164
164
|
backends: [customBackend],
|
|
165
165
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as _doki_land_live2d_core from '@doki-land/live2d-core';
|
|
2
|
-
import { Live2dStageAssets, ModelSource, AssetResolver, ModelAsset, LoadProgress, ModelSettings, InternalModel, Live2dActor, CreateActorOptions, ActorTransform, PlayMotionActorOptions, ActorHit, CreateLive2dStageOptions, Live2dStage, PointerTrackingPolicy, StagePointerEvent,
|
|
3
|
-
export { ActorHit, ActorInstance, ActorTransform, AssetResolver, CreateActorOptions, CreateLive2dStageOptions, DEFAULT_ACTOR_TRANSFORM, EventEmitter, FrameProfile, FrameSnapshot, InternalModel,
|
|
2
|
+
import { Live2dStageAssets, ModelSource, AssetResolver, ModelAsset, LoadProgress, ModelSettings, InternalModel, Live2dActor, CreateActorOptions, ActorTransform, PlayMotionActorOptions, ActorHit, CreateLive2dStageOptions, Live2dStage, PointerTrackingPolicy, StagePointerEvent, Live2dSession, HitAreaDefinition } from '@doki-land/live2d-core';
|
|
3
|
+
export { ActorHit, ActorInstance, ActorTransform, AssetResolver, CreateActorOptions, CreateLive2dStageOptions, DEFAULT_ACTOR_TRANSFORM, EventEmitter, FrameProfile, FrameSnapshot, InternalModel, Live2dActor, Live2dSession, Live2dStage, Live2dStageAssets, LoadProgress, LoadProgressStage, ModelAsset, ModelFormat, ModelInstance, ModelProgram, ModelSettings, ModelSource, PlayMotionActorOptions, PointerTrackingMode, PointerTrackingPolicy, SessionPhase, SessionState, StagePointerEvent, StageUpdateMode } from '@doki-land/live2d-core';
|
|
4
4
|
export { DEFAULT_NPM_CDN, resolveModelSourceUrl, resolveNpmSpecifier } from '@doki-land/live2d-loader';
|
|
5
5
|
import * as _doki_land_live2d_renderer from '@doki-land/live2d-renderer';
|
|
6
6
|
import { ModelBackend, compileSharedModelCompile, TextureData, Renderer, ParameterBinding, DrawableMesh, RendererKind } from '@doki-land/live2d-renderer';
|
|
@@ -219,7 +219,9 @@ declare class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
219
219
|
#private;
|
|
220
220
|
constructor(options: ModelAssetRegistryOptions);
|
|
221
221
|
load(source: ModelSource, resolver?: AssetResolver): Promise<ModelAsset>;
|
|
222
|
-
acquire(source: ModelSource, resolver: AssetResolver | undefined, onProgress?: (payload: LoadProgress) => void
|
|
222
|
+
acquire(source: ModelSource, resolver: AssetResolver | undefined, onProgress?: (payload: LoadProgress) => void, options?: {
|
|
223
|
+
signal?: AbortSignal;
|
|
224
|
+
}): Promise<ModelAssetLease>;
|
|
223
225
|
acquireExisting(asset: ModelAsset): ModelAssetLease;
|
|
224
226
|
destroy(): void;
|
|
225
227
|
}
|
|
@@ -250,7 +252,9 @@ declare class ActorModelSlot {
|
|
|
250
252
|
/** Load-time stable id → binding map (binding.value updated on setParameter). */
|
|
251
253
|
parameterMap(): ReadonlyMap<string, ParameterBinding>;
|
|
252
254
|
resolveParameter(id: string): number | undefined;
|
|
253
|
-
load(source: ModelSource, resolver?: AssetResolver
|
|
255
|
+
load(source: ModelSource, resolver?: AssetResolver, options?: {
|
|
256
|
+
signal?: AbortSignal;
|
|
257
|
+
}): Promise<InternalModel>;
|
|
254
258
|
loadAsset(asset: ModelAsset): Promise<InternalModel>;
|
|
255
259
|
setParameter(id: string, value: number): void;
|
|
256
260
|
listParameters(): readonly ParameterBinding[];
|
|
@@ -285,6 +289,19 @@ declare class Live2dActorImpl implements Live2dActor {
|
|
|
285
289
|
readonly id: string;
|
|
286
290
|
readonly creationIndex: number;
|
|
287
291
|
constructor(options: CreateActorOptions | undefined, shared: Live2dActorImplOptions);
|
|
292
|
+
/** Bridge MotionPlayer lifecycle into Stage/facade event buses. */
|
|
293
|
+
setMotionEventHandlers(handlers: {
|
|
294
|
+
onStart?: (payload: {
|
|
295
|
+
group: string;
|
|
296
|
+
index: number;
|
|
297
|
+
slot: string;
|
|
298
|
+
}) => void;
|
|
299
|
+
onFinish?: (payload: {
|
|
300
|
+
group: string;
|
|
301
|
+
index: number;
|
|
302
|
+
slot: string;
|
|
303
|
+
}) => void;
|
|
304
|
+
}): void;
|
|
288
305
|
get model(): InternalModel | null;
|
|
289
306
|
get visible(): boolean;
|
|
290
307
|
set visible(value: boolean);
|
|
@@ -296,7 +313,9 @@ declare class Live2dActorImpl implements Live2dActor {
|
|
|
296
313
|
set order(value: number);
|
|
297
314
|
getTransform(): ActorTransform;
|
|
298
315
|
setTransform(patch: Partial<ActorTransform>): void;
|
|
299
|
-
load(source: Parameters<Live2dActor["load"]>[0], resolver?: Parameters<Live2dActor["load"]>[1]
|
|
316
|
+
load(source: Parameters<Live2dActor["load"]>[0], resolver?: Parameters<Live2dActor["load"]>[1], options?: {
|
|
317
|
+
signal?: AbortSignal;
|
|
318
|
+
}): Promise<InternalModel>;
|
|
300
319
|
loadAsset(asset: ModelAsset): Promise<InternalModel>;
|
|
301
320
|
setParameter(id: string, value: number): void;
|
|
302
321
|
listParameters(): readonly _doki_land_live2d_renderer.ParameterBinding[];
|
|
@@ -362,13 +381,13 @@ declare class Live2dStageImpl implements Live2dStage {
|
|
|
362
381
|
}
|
|
363
382
|
declare function createLive2dStage(options?: CreateLive2dStageFullOptions): Live2dStage;
|
|
364
383
|
|
|
365
|
-
interface
|
|
384
|
+
interface CreateLive2dOptions {
|
|
366
385
|
backends?: ModelBackend[];
|
|
367
386
|
renderer?: Renderer;
|
|
368
387
|
prefer?: RendererKind[];
|
|
369
388
|
updateMode?: "auto" | "manual";
|
|
370
389
|
}
|
|
371
|
-
interface
|
|
390
|
+
interface Live2dRuntime extends Live2dSession {
|
|
372
391
|
readonly renderer: Renderer;
|
|
373
392
|
readonly backends: readonly ModelBackend[];
|
|
374
393
|
readonly stage: Live2dStageImpl;
|
|
@@ -401,7 +420,44 @@ interface Live2DRuntime extends Live2DSession {
|
|
|
401
420
|
}
|
|
402
421
|
|
|
403
422
|
/** Wire moc backends and a renderer into one single-actor session. */
|
|
404
|
-
declare function
|
|
423
|
+
declare function createLive2d(options?: CreateLive2dOptions): Live2dRuntime;
|
|
424
|
+
|
|
425
|
+
/** One physics output destination (parameter id only for the thin gate). */
|
|
426
|
+
interface Physics3Output {
|
|
427
|
+
readonly destinationId: string;
|
|
428
|
+
}
|
|
429
|
+
/** One PhysicsSettings entry with output destinations. */
|
|
430
|
+
interface Physics3Setting {
|
|
431
|
+
readonly id: string;
|
|
432
|
+
readonly outputs: readonly Physics3Output[];
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Parsed Cubism `physics3.json` (minimal).
|
|
436
|
+
* Full spring / pendulum simulation is intentionally out of scope for this gate.
|
|
437
|
+
*/
|
|
438
|
+
interface Physics3Clip {
|
|
439
|
+
readonly settings: readonly Physics3Setting[];
|
|
440
|
+
/** Flattened destination parameter ids from all outputs. */
|
|
441
|
+
readonly outputParameterIds: readonly string[];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
interface Physics3ApplyBinding {
|
|
445
|
+
readonly value: number;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Minimal Physics3 apply gate — **not** a full Cubism spring / pendulum solver.
|
|
449
|
+
*
|
|
450
|
+
* For each output destination parameter id that exists in `bindings`, performs a
|
|
451
|
+
* trivial identity step (write current value back). Exists so load → update can
|
|
452
|
+
* exercise the physics clip API; real physics evaluation is a later milestone.
|
|
453
|
+
*/
|
|
454
|
+
declare function applyPhysics3(clip: Physics3Clip, _deltaTimeSeconds: number, bindings: ReadonlyMap<string, Physics3ApplyBinding>, setParameter: (id: string, value: number) => void): void;
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Parse Cubism `physics3.json` enough to expose PhysicsSettings output parameter ids.
|
|
458
|
+
* Tolerates minimal fixtures (missing / empty PhysicsSettings → empty clip).
|
|
459
|
+
*/
|
|
460
|
+
declare function parsePhysics3(json: unknown): Physics3Clip;
|
|
405
461
|
|
|
406
462
|
/** Parsed Cubism `pose3.json` / legacy `pose.json` groups. */
|
|
407
463
|
interface Pose3Clip {
|
|
@@ -447,16 +503,16 @@ declare function resolveHitAreaName(input: ResolveHitAreaInput): string;
|
|
|
447
503
|
* `@doki-land/live2d` — public facade.
|
|
448
504
|
*
|
|
449
505
|
* Layout:
|
|
450
|
-
* - `facade/` — `
|
|
506
|
+
* - `facade/` — `createLive2d()` default stage + actor entry
|
|
451
507
|
* - `motion/` — motion3 parse + playback
|
|
452
508
|
* - `stage/` — multi-actor stage, assets, transforms
|
|
453
509
|
* - `reexports/` — optional subpath `@doki-land/live2d/{core,loader,renderer}`
|
|
454
510
|
*
|
|
455
511
|
* ```ts
|
|
456
|
-
* import {
|
|
512
|
+
* import { createLive2d } from "@doki-land/live2d";
|
|
457
513
|
* ```
|
|
458
514
|
*/
|
|
459
515
|
|
|
460
516
|
declare const LIVE2D_VERSION: "0.0.0";
|
|
461
517
|
|
|
462
|
-
export { type
|
|
518
|
+
export { type CreateLive2dOptions, type CreateLive2dStageFullOptions, type Expression3Clip, type Expression3Parameter, type ExpressionBlendMode, LIVE2D_VERSION, type Live2dRuntime, type Motion3Clip, type MotionApplySample, MotionPlayer, MotionPriority, type Physics3ApplyBinding, type Physics3Clip, type Physics3Output, type Physics3Setting, type PlayMotionOptions, type Pose3Clip, allocateActorId, applyExpression3Clip, applyPhysics3, applyPose3Activation, blendMotionLayers, createLive2d, createLive2dStage, evaluateCurve, evaluateMotion3, focusParameterUpdates, parseExpression3, parseMotion3, parsePhysics3, parsePose3, resolveHitAreaName };
|
package/dist/index.js
CHANGED
|
@@ -573,6 +573,60 @@ function optionalNum(v) {
|
|
|
573
573
|
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
574
574
|
}
|
|
575
575
|
|
|
576
|
+
// src/physics/apply-physics3.ts
|
|
577
|
+
function applyPhysics3(clip, _deltaTimeSeconds, bindings, setParameter) {
|
|
578
|
+
for (const id of clip.outputParameterIds) {
|
|
579
|
+
const binding = bindings.get(id);
|
|
580
|
+
if (!binding) continue;
|
|
581
|
+
setParameter(id, binding.value);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// src/physics/parse-physics3.ts
|
|
586
|
+
function parsePhysics3(json) {
|
|
587
|
+
if (!json || typeof json !== "object") {
|
|
588
|
+
throw new Error(
|
|
589
|
+
"@doki-land/live2d: physics3 json root must be an object"
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
const root = json;
|
|
593
|
+
const settingsRaw = root.PhysicsSettings;
|
|
594
|
+
if (settingsRaw === void 0 || settingsRaw === null) {
|
|
595
|
+
return { settings: [], outputParameterIds: [] };
|
|
596
|
+
}
|
|
597
|
+
if (!Array.isArray(settingsRaw)) {
|
|
598
|
+
throw new Error("@doki-land/live2d: PhysicsSettings must be an array");
|
|
599
|
+
}
|
|
600
|
+
const settings = [];
|
|
601
|
+
const outputParameterIds = [];
|
|
602
|
+
for (let si = 0; si < settingsRaw.length; si++) {
|
|
603
|
+
const entry = settingsRaw[si];
|
|
604
|
+
if (!entry || typeof entry !== "object") {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`@doki-land/live2d: PhysicsSettings[${si}] must be an object`
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
const rec = entry;
|
|
610
|
+
const id = typeof rec.Id === "string" ? rec.Id : `PhysicsSetting${si}`;
|
|
611
|
+
const outputsRaw = rec.Output;
|
|
612
|
+
const outputs = [];
|
|
613
|
+
if (Array.isArray(outputsRaw)) {
|
|
614
|
+
for (let oi = 0; oi < outputsRaw.length; oi++) {
|
|
615
|
+
const out = outputsRaw[oi];
|
|
616
|
+
if (!out || typeof out !== "object") continue;
|
|
617
|
+
const dest = out.Destination;
|
|
618
|
+
if (!dest || typeof dest !== "object") continue;
|
|
619
|
+
const destId = dest.Id;
|
|
620
|
+
if (typeof destId !== "string" || !destId) continue;
|
|
621
|
+
outputs.push({ destinationId: destId });
|
|
622
|
+
outputParameterIds.push(destId);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
settings.push({ id, outputs });
|
|
626
|
+
}
|
|
627
|
+
return { settings, outputParameterIds };
|
|
628
|
+
}
|
|
629
|
+
|
|
576
630
|
// src/pose/apply-pose3.ts
|
|
577
631
|
function applyPose3Activation(clip, activatedPartId, setPartOpacity) {
|
|
578
632
|
for (const group of clip.groups) {
|
|
@@ -645,6 +699,7 @@ var ActorModelSlot = class {
|
|
|
645
699
|
#expressionCache = /* @__PURE__ */ new Map();
|
|
646
700
|
#activeExpression = null;
|
|
647
701
|
#poseClip = null;
|
|
702
|
+
#physicsClip = null;
|
|
648
703
|
constructor(options) {
|
|
649
704
|
this.#assets = options.assets;
|
|
650
705
|
this.#renderer = options.renderer;
|
|
@@ -745,6 +800,27 @@ var ActorModelSlot = class {
|
|
|
745
800
|
this.#poseClip = null;
|
|
746
801
|
}
|
|
747
802
|
}
|
|
803
|
+
async #loadPhysicsClip() {
|
|
804
|
+
this.#physicsClip = null;
|
|
805
|
+
const physicsPath = this.#model?.settings.physics;
|
|
806
|
+
if (!physicsPath || !this.#lease) return;
|
|
807
|
+
try {
|
|
808
|
+
const json = await this.#lease.resolver.fetchJson(physicsPath);
|
|
809
|
+
this.#physicsClip = parsePhysics3(json);
|
|
810
|
+
} catch {
|
|
811
|
+
this.#physicsClip = null;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
#applyPhysicsLayer(deltaTimeSeconds) {
|
|
815
|
+
if (!this.#physicsClip || !this.#model || !this.#backend) return;
|
|
816
|
+
applyPhysics3(
|
|
817
|
+
this.#physicsClip,
|
|
818
|
+
deltaTimeSeconds,
|
|
819
|
+
this.#paramById,
|
|
820
|
+
(id, value) => this.#backend?.setParameter?.(this.#model, id, value)
|
|
821
|
+
);
|
|
822
|
+
this.#syncParamCacheFromBackend();
|
|
823
|
+
}
|
|
748
824
|
#rebuildParamCache() {
|
|
749
825
|
this.#paramById.clear();
|
|
750
826
|
this.#paramIndexById.clear();
|
|
@@ -774,24 +850,37 @@ var ActorModelSlot = class {
|
|
|
774
850
|
}
|
|
775
851
|
return this.#paramIndexById.get(id);
|
|
776
852
|
}
|
|
777
|
-
async load(source, resolver) {
|
|
853
|
+
async load(source, resolver, options) {
|
|
778
854
|
const gen = ++this.#loadGeneration;
|
|
855
|
+
const signal = options?.signal;
|
|
856
|
+
if (signal?.aborted) {
|
|
857
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
858
|
+
}
|
|
859
|
+
const onAbort = () => {
|
|
860
|
+
this.#loadGeneration += 1;
|
|
861
|
+
};
|
|
862
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
779
863
|
this.#report({
|
|
780
864
|
stage: "mounting",
|
|
781
865
|
progress: 0.01,
|
|
782
866
|
detail: "prepare draw pass"
|
|
783
867
|
});
|
|
784
868
|
const drawPass = this.ensureDrawPass();
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
869
|
+
try {
|
|
870
|
+
const lease = await this.#assets.acquire(
|
|
871
|
+
source,
|
|
872
|
+
resolver,
|
|
873
|
+
(p) => this.#report(p),
|
|
874
|
+
{ signal }
|
|
875
|
+
);
|
|
876
|
+
if (gen !== this.#loadGeneration) {
|
|
877
|
+
lease.release();
|
|
878
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
879
|
+
}
|
|
880
|
+
return await this.#attachLease(lease, drawPass, gen);
|
|
881
|
+
} finally {
|
|
882
|
+
signal?.removeEventListener("abort", onAbort);
|
|
793
883
|
}
|
|
794
|
-
return await this.#attachLease(lease, drawPass, gen);
|
|
795
884
|
}
|
|
796
885
|
async loadAsset(asset) {
|
|
797
886
|
const gen = ++this.#loadGeneration;
|
|
@@ -817,6 +906,7 @@ var ActorModelSlot = class {
|
|
|
817
906
|
this.#motionPlayer.clear();
|
|
818
907
|
this.#activeExpression = null;
|
|
819
908
|
this.#poseClip = null;
|
|
909
|
+
this.#physicsClip = null;
|
|
820
910
|
this.#releaseLease();
|
|
821
911
|
const { model, backend } = await lease.createInstance(this.#renderer);
|
|
822
912
|
if (gen !== this.#loadGeneration) {
|
|
@@ -833,6 +923,7 @@ var ActorModelSlot = class {
|
|
|
833
923
|
this.#backend = backend;
|
|
834
924
|
this.#rebuildParamCache();
|
|
835
925
|
await this.#loadPoseClip();
|
|
926
|
+
await this.#loadPhysicsClip();
|
|
836
927
|
this.#report({
|
|
837
928
|
stage: "ready",
|
|
838
929
|
progress: 1,
|
|
@@ -907,6 +998,7 @@ var ActorModelSlot = class {
|
|
|
907
998
|
this.#applyMotionSamples(this.#motionPlayer.update(deltaTimeSeconds));
|
|
908
999
|
this.#tickExpression(deltaTimeSeconds);
|
|
909
1000
|
this.#applyExpressionLayer();
|
|
1001
|
+
this.#applyPhysicsLayer(deltaTimeSeconds);
|
|
910
1002
|
this.#backend.updateModel(this.#model, deltaTimeSeconds);
|
|
911
1003
|
return this.#backend.getDrawables(this.#model);
|
|
912
1004
|
}
|
|
@@ -946,6 +1038,7 @@ var ActorModelSlot = class {
|
|
|
946
1038
|
this.#motionPlayer.clear();
|
|
947
1039
|
this.#activeExpression = null;
|
|
948
1040
|
this.#poseClip = null;
|
|
1041
|
+
this.#physicsClip = null;
|
|
949
1042
|
if (this.#model && this.#backend) {
|
|
950
1043
|
this.#backend.destroyModel(this.#model);
|
|
951
1044
|
}
|
|
@@ -1171,9 +1264,18 @@ var Live2dActorImpl = class {
|
|
|
1171
1264
|
this.#order = options?.order ?? 0;
|
|
1172
1265
|
this.#slot = new ActorModelSlot({
|
|
1173
1266
|
assets: shared.assets,
|
|
1174
|
-
renderer: shared.renderer
|
|
1267
|
+
renderer: shared.renderer,
|
|
1268
|
+
onMotionStart: (payload) => this.#onMotionStart?.(payload),
|
|
1269
|
+
onMotionFinish: (payload) => this.#onMotionFinish?.(payload)
|
|
1175
1270
|
});
|
|
1176
1271
|
}
|
|
1272
|
+
#onMotionStart = null;
|
|
1273
|
+
#onMotionFinish = null;
|
|
1274
|
+
/** Bridge MotionPlayer lifecycle into Stage/facade event buses. */
|
|
1275
|
+
setMotionEventHandlers(handlers) {
|
|
1276
|
+
this.#onMotionStart = handlers.onStart ?? null;
|
|
1277
|
+
this.#onMotionFinish = handlers.onFinish ?? null;
|
|
1278
|
+
}
|
|
1177
1279
|
get model() {
|
|
1178
1280
|
return this.#slot.model;
|
|
1179
1281
|
}
|
|
@@ -1210,11 +1312,11 @@ var Live2dActorImpl = class {
|
|
|
1210
1312
|
...patch
|
|
1211
1313
|
});
|
|
1212
1314
|
}
|
|
1213
|
-
async load(source, resolver) {
|
|
1315
|
+
async load(source, resolver, options) {
|
|
1214
1316
|
if (this.#destroyed) {
|
|
1215
1317
|
throw new Error("@doki-land/live2d: actor destroyed");
|
|
1216
1318
|
}
|
|
1217
|
-
return await this.#slot.load(source, resolver);
|
|
1319
|
+
return await this.#slot.load(source, resolver, options);
|
|
1218
1320
|
}
|
|
1219
1321
|
async loadAsset(asset) {
|
|
1220
1322
|
if (this.#destroyed) {
|
|
@@ -1363,10 +1465,10 @@ function createSingleActorFacade(stage, actor, backends) {
|
|
|
1363
1465
|
}
|
|
1364
1466
|
);
|
|
1365
1467
|
},
|
|
1366
|
-
async loadModel(source, resolver) {
|
|
1468
|
+
async loadModel(source, resolver, options) {
|
|
1367
1469
|
setPhase("loading");
|
|
1368
1470
|
try {
|
|
1369
|
-
const model = await actor.load(source, resolver);
|
|
1471
|
+
const model = await actor.load(source, resolver, options);
|
|
1370
1472
|
lastError = null;
|
|
1371
1473
|
setPhase("live");
|
|
1372
1474
|
events.emit("ready", { modelId: model.id });
|
|
@@ -1479,6 +1581,10 @@ function createSingleActorFacade(stage, actor, backends) {
|
|
|
1479
1581
|
events.clear();
|
|
1480
1582
|
}
|
|
1481
1583
|
};
|
|
1584
|
+
actor.setMotionEventHandlers({
|
|
1585
|
+
onStart: (payload) => events.emit("motion:start", payload),
|
|
1586
|
+
onFinish: (payload) => events.emit("motion:finish", payload)
|
|
1587
|
+
});
|
|
1482
1588
|
return runtime;
|
|
1483
1589
|
}
|
|
1484
1590
|
|
|
@@ -1608,8 +1714,13 @@ var ModelAssetRegistry = class {
|
|
|
1608
1714
|
const entry = await this.#ensureEntry(source, resolver);
|
|
1609
1715
|
return new ModelAssetHandle(entry);
|
|
1610
1716
|
}
|
|
1611
|
-
async acquire(source, resolver, onProgress) {
|
|
1612
|
-
const entry = await this.#ensureEntry(
|
|
1717
|
+
async acquire(source, resolver, onProgress, options) {
|
|
1718
|
+
const entry = await this.#ensureEntry(
|
|
1719
|
+
source,
|
|
1720
|
+
resolver,
|
|
1721
|
+
onProgress,
|
|
1722
|
+
options
|
|
1723
|
+
);
|
|
1613
1724
|
entry.refCount += 1;
|
|
1614
1725
|
return this.#leaseFromEntry(entry);
|
|
1615
1726
|
}
|
|
@@ -1660,13 +1771,19 @@ var ModelAssetRegistry = class {
|
|
|
1660
1771
|
}
|
|
1661
1772
|
};
|
|
1662
1773
|
}
|
|
1663
|
-
async #ensureEntry(source, resolver, onProgress) {
|
|
1774
|
+
async #ensureEntry(source, resolver, onProgress, options) {
|
|
1664
1775
|
const key = resolveModelAssetKey(source);
|
|
1665
1776
|
const existing = this.#entries.get(key);
|
|
1666
1777
|
if (existing) return existing;
|
|
1667
1778
|
let pending = this.#inFlight.get(key);
|
|
1668
1779
|
if (!pending) {
|
|
1669
|
-
pending = this.#compileEntry(
|
|
1780
|
+
pending = this.#compileEntry(
|
|
1781
|
+
key,
|
|
1782
|
+
source,
|
|
1783
|
+
resolver,
|
|
1784
|
+
onProgress,
|
|
1785
|
+
options
|
|
1786
|
+
);
|
|
1670
1787
|
this.#inFlight.set(key, pending);
|
|
1671
1788
|
}
|
|
1672
1789
|
try {
|
|
@@ -1677,8 +1794,12 @@ var ModelAssetRegistry = class {
|
|
|
1677
1794
|
this.#inFlight.delete(key);
|
|
1678
1795
|
}
|
|
1679
1796
|
}
|
|
1680
|
-
async #compileEntry(key, source, resolver, onProgress) {
|
|
1797
|
+
async #compileEntry(key, source, resolver, onProgress, options) {
|
|
1681
1798
|
const notify = (payload) => onProgress?.(payload);
|
|
1799
|
+
const signal = options?.signal;
|
|
1800
|
+
if (signal?.aborted) {
|
|
1801
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
1802
|
+
}
|
|
1682
1803
|
notify({
|
|
1683
1804
|
stage: "resolve",
|
|
1684
1805
|
progress: 0.02,
|
|
@@ -1707,19 +1828,26 @@ var ModelAssetRegistry = class {
|
|
|
1707
1828
|
progress: 0.05,
|
|
1708
1829
|
detail: fetchUrl
|
|
1709
1830
|
});
|
|
1710
|
-
json = await fetchModelJson(
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1831
|
+
json = await fetchModelJson(
|
|
1832
|
+
fetchUrl,
|
|
1833
|
+
(u) => {
|
|
1834
|
+
const ratio = u.bytesTotal && u.bytesTotal > 0 ? u.bytesLoaded / u.bytesTotal : 0;
|
|
1835
|
+
notify({
|
|
1836
|
+
stage: "settings",
|
|
1837
|
+
progress: lerp(0.05, 0.22, ratio),
|
|
1838
|
+
detail: fetchUrl,
|
|
1839
|
+
bytesLoaded: u.bytesLoaded,
|
|
1840
|
+
bytesTotal: u.bytesTotal
|
|
1841
|
+
});
|
|
1842
|
+
},
|
|
1843
|
+
{ signal }
|
|
1844
|
+
);
|
|
1720
1845
|
baseUrl = fetchUrl;
|
|
1721
1846
|
settingsUrl = fetchUrl;
|
|
1722
1847
|
}
|
|
1848
|
+
if (signal?.aborted) {
|
|
1849
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
1850
|
+
}
|
|
1723
1851
|
const settings = normalizeModelSettings(json, settingsUrl);
|
|
1724
1852
|
notify({
|
|
1725
1853
|
stage: "moc",
|
|
@@ -1727,6 +1855,7 @@ var ModelAssetRegistry = class {
|
|
|
1727
1855
|
detail: settings.moc
|
|
1728
1856
|
});
|
|
1729
1857
|
const assetResolver = resolver ?? createUrlAssetResolver(baseUrl, {
|
|
1858
|
+
signal,
|
|
1730
1859
|
onBytesProgress: (assetKey, u) => {
|
|
1731
1860
|
const isMoc = assetKey === settings.moc;
|
|
1732
1861
|
const ratio = u.bytesTotal && u.bytesTotal > 0 ? u.bytesLoaded / u.bytesTotal : 0;
|
|
@@ -1756,6 +1885,9 @@ var ModelAssetRegistry = class {
|
|
|
1756
1885
|
detail: `decode ${settings.format}`
|
|
1757
1886
|
});
|
|
1758
1887
|
const mocBytes = await assetResolver.fetchBytes(settings.moc);
|
|
1888
|
+
if (signal?.aborted) {
|
|
1889
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
1890
|
+
}
|
|
1759
1891
|
const sharedCompile = compileSharedModelCompile(settings, mocBytes);
|
|
1760
1892
|
const textures = [];
|
|
1761
1893
|
if (settings.textures.length > 0) {
|
|
@@ -1779,6 +1911,9 @@ var ModelAssetRegistry = class {
|
|
|
1779
1911
|
})
|
|
1780
1912
|
);
|
|
1781
1913
|
}
|
|
1914
|
+
if (signal?.aborted) {
|
|
1915
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
1916
|
+
}
|
|
1782
1917
|
notify({
|
|
1783
1918
|
stage: "ready",
|
|
1784
1919
|
progress: 1,
|
|
@@ -2148,7 +2283,7 @@ function createLive2dStage(options) {
|
|
|
2148
2283
|
}
|
|
2149
2284
|
|
|
2150
2285
|
// src/facade/create-live2d.ts
|
|
2151
|
-
function
|
|
2286
|
+
function createLive2d(options = {}) {
|
|
2152
2287
|
const backends = options.backends ?? [
|
|
2153
2288
|
createMoc2Backend2(),
|
|
2154
2289
|
createMoc3Backend2()
|
|
@@ -2175,10 +2310,11 @@ export {
|
|
|
2175
2310
|
MotionPriority,
|
|
2176
2311
|
allocateActorId,
|
|
2177
2312
|
applyExpression3Clip,
|
|
2313
|
+
applyPhysics3,
|
|
2178
2314
|
applyPose3Activation,
|
|
2179
2315
|
blendMotionLayers,
|
|
2180
2316
|
createCanvas2DRenderer,
|
|
2181
|
-
|
|
2317
|
+
createLive2d,
|
|
2182
2318
|
createLive2dStage,
|
|
2183
2319
|
createMoc2Backend3 as createMoc2Backend,
|
|
2184
2320
|
createMoc3Backend3 as createMoc3Backend,
|
|
@@ -2195,6 +2331,7 @@ export {
|
|
|
2195
2331
|
parseCpuProgram,
|
|
2196
2332
|
parseExpression3,
|
|
2197
2333
|
parseMotion3,
|
|
2334
|
+
parsePhysics3,
|
|
2198
2335
|
parsePose3,
|
|
2199
2336
|
resolveHitAreaName,
|
|
2200
2337
|
resolveModelSourceUrl2 as resolveModelSourceUrl,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doki-land/live2d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.25",
|
|
4
4
|
"description": "Live2D in the browser — load moc2/moc3 models, Stage + multi-actor, motion; WebGPU/WebGL2/Canvas2D. Main entry for live2d.ts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"test": "vitest run --passWithNoTests"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@doki-land/live2d-core": "0.0.
|
|
63
|
-
"@doki-land/live2d-loader": "0.0.
|
|
64
|
-
"@doki-land/live2d-renderer": "0.0.
|
|
62
|
+
"@doki-land/live2d-core": "0.0.25",
|
|
63
|
+
"@doki-land/live2d-loader": "0.0.25",
|
|
64
|
+
"@doki-land/live2d-renderer": "0.0.25"
|
|
65
65
|
},
|
|
66
66
|
"sideEffects": false
|
|
67
67
|
}
|
|
@@ -5,15 +5,15 @@ import {
|
|
|
5
5
|
} from "@doki-land/live2d-renderer";
|
|
6
6
|
import { allocateActorId } from "../stage/actor.js";
|
|
7
7
|
import {
|
|
8
|
-
type
|
|
8
|
+
type CreateLive2dOptions,
|
|
9
9
|
createSingleActorFacade,
|
|
10
|
-
type
|
|
10
|
+
type Live2dRuntime,
|
|
11
11
|
} from "../stage/single-facade.js";
|
|
12
12
|
import { createLive2dStage } from "../stage/stage.js";
|
|
13
13
|
|
|
14
14
|
export type {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
CreateLive2dOptions,
|
|
16
|
+
Live2dRuntime,
|
|
17
17
|
} from "../stage/single-facade.js";
|
|
18
18
|
export {
|
|
19
19
|
MotionPriority,
|
|
@@ -21,7 +21,7 @@ export {
|
|
|
21
21
|
} from "../stage/single-facade.js";
|
|
22
22
|
|
|
23
23
|
/** Wire moc backends and a renderer into one single-actor session. */
|
|
24
|
-
export function
|
|
24
|
+
export function createLive2d(options: CreateLive2dOptions = {}): Live2dRuntime {
|
|
25
25
|
const backends = options.backends ?? [
|
|
26
26
|
createMoc2Backend(),
|
|
27
27
|
createMoc3Backend(),
|
package/src/index.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* `@doki-land/live2d` — public facade.
|
|
3
3
|
*
|
|
4
4
|
* Layout:
|
|
5
|
-
* - `facade/` — `
|
|
5
|
+
* - `facade/` — `createLive2d()` default stage + actor entry
|
|
6
6
|
* - `motion/` — motion3 parse + playback
|
|
7
7
|
* - `stage/` — multi-actor stage, assets, transforms
|
|
8
8
|
* - `reexports/` — optional subpath `@doki-land/live2d/{core,loader,renderer}`
|
|
9
9
|
*
|
|
10
10
|
* ```ts
|
|
11
|
-
* import {
|
|
11
|
+
* import { createLive2d } from "@doki-land/live2d";
|
|
12
12
|
* ```
|
|
13
13
|
*/
|
|
14
14
|
|
|
@@ -22,8 +22,8 @@ export type {
|
|
|
22
22
|
FrameProfile,
|
|
23
23
|
FrameSnapshot,
|
|
24
24
|
InternalModel,
|
|
25
|
-
Live2DSession,
|
|
26
25
|
Live2dActor,
|
|
26
|
+
Live2dSession,
|
|
27
27
|
Live2dStage,
|
|
28
28
|
Live2dStageAssets,
|
|
29
29
|
LoadProgress,
|
|
@@ -74,9 +74,9 @@ export {
|
|
|
74
74
|
parseExpression3,
|
|
75
75
|
} from "./expression/index.js";
|
|
76
76
|
export {
|
|
77
|
-
type
|
|
78
|
-
|
|
79
|
-
type
|
|
77
|
+
type CreateLive2dOptions,
|
|
78
|
+
createLive2d,
|
|
79
|
+
type Live2dRuntime,
|
|
80
80
|
MotionPriority,
|
|
81
81
|
type PlayMotionOptions,
|
|
82
82
|
} from "./facade/create-live2d.js";
|
|
@@ -89,6 +89,14 @@ export {
|
|
|
89
89
|
MotionPlayer,
|
|
90
90
|
parseMotion3,
|
|
91
91
|
} from "./motion/index.js";
|
|
92
|
+
export {
|
|
93
|
+
applyPhysics3,
|
|
94
|
+
type Physics3ApplyBinding,
|
|
95
|
+
type Physics3Clip,
|
|
96
|
+
type Physics3Output,
|
|
97
|
+
type Physics3Setting,
|
|
98
|
+
parsePhysics3,
|
|
99
|
+
} from "./physics/index.js";
|
|
92
100
|
export {
|
|
93
101
|
applyPose3Activation,
|
|
94
102
|
type Pose3Clip,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Physics3Clip } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface Physics3ApplyBinding {
|
|
4
|
+
readonly value: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal Physics3 apply gate — **not** a full Cubism spring / pendulum solver.
|
|
9
|
+
*
|
|
10
|
+
* For each output destination parameter id that exists in `bindings`, performs a
|
|
11
|
+
* trivial identity step (write current value back). Exists so load → update can
|
|
12
|
+
* exercise the physics clip API; real physics evaluation is a later milestone.
|
|
13
|
+
*/
|
|
14
|
+
export function applyPhysics3(
|
|
15
|
+
clip: Physics3Clip,
|
|
16
|
+
_deltaTimeSeconds: number,
|
|
17
|
+
bindings: ReadonlyMap<string, Physics3ApplyBinding>,
|
|
18
|
+
setParameter: (id: string, value: number) => void,
|
|
19
|
+
): void {
|
|
20
|
+
for (const id of clip.outputParameterIds) {
|
|
21
|
+
const binding = bindings.get(id);
|
|
22
|
+
if (!binding) continue;
|
|
23
|
+
// Identity / zero-step: prove the output walk without changing values.
|
|
24
|
+
setParameter(id, binding.value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Physics3Clip, Physics3Output, Physics3Setting } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse Cubism `physics3.json` enough to expose PhysicsSettings output parameter ids.
|
|
5
|
+
* Tolerates minimal fixtures (missing / empty PhysicsSettings → empty clip).
|
|
6
|
+
*/
|
|
7
|
+
export function parsePhysics3(json: unknown): Physics3Clip {
|
|
8
|
+
if (!json || typeof json !== "object") {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"@doki-land/live2d: physics3 json root must be an object",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
const root = json as Record<string, unknown>;
|
|
14
|
+
const settingsRaw = root.PhysicsSettings;
|
|
15
|
+
if (settingsRaw === undefined || settingsRaw === null) {
|
|
16
|
+
return { settings: [], outputParameterIds: [] };
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(settingsRaw)) {
|
|
19
|
+
throw new Error("@doki-land/live2d: PhysicsSettings must be an array");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const settings: Physics3Setting[] = [];
|
|
23
|
+
const outputParameterIds: string[] = [];
|
|
24
|
+
|
|
25
|
+
for (let si = 0; si < settingsRaw.length; si++) {
|
|
26
|
+
const entry = settingsRaw[si];
|
|
27
|
+
if (!entry || typeof entry !== "object") {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`@doki-land/live2d: PhysicsSettings[${si}] must be an object`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const rec = entry as Record<string, unknown>;
|
|
33
|
+
const id = typeof rec.Id === "string" ? rec.Id : `PhysicsSetting${si}`;
|
|
34
|
+
const outputsRaw = rec.Output;
|
|
35
|
+
const outputs: Physics3Output[] = [];
|
|
36
|
+
if (Array.isArray(outputsRaw)) {
|
|
37
|
+
for (let oi = 0; oi < outputsRaw.length; oi++) {
|
|
38
|
+
const out = outputsRaw[oi];
|
|
39
|
+
if (!out || typeof out !== "object") continue;
|
|
40
|
+
const dest = (out as Record<string, unknown>).Destination;
|
|
41
|
+
if (!dest || typeof dest !== "object") continue;
|
|
42
|
+
const destId = (dest as Record<string, unknown>).Id;
|
|
43
|
+
if (typeof destId !== "string" || !destId) continue;
|
|
44
|
+
outputs.push({ destinationId: destId });
|
|
45
|
+
outputParameterIds.push(destId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
settings.push({ id, outputs });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { settings, outputParameterIds };
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** One physics output destination (parameter id only for the thin gate). */
|
|
2
|
+
export interface Physics3Output {
|
|
3
|
+
readonly destinationId: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** One PhysicsSettings entry with output destinations. */
|
|
7
|
+
export interface Physics3Setting {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly outputs: readonly Physics3Output[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parsed Cubism `physics3.json` (minimal).
|
|
14
|
+
* Full spring / pendulum simulation is intentionally out of scope for this gate.
|
|
15
|
+
*/
|
|
16
|
+
export interface Physics3Clip {
|
|
17
|
+
readonly settings: readonly Physics3Setting[];
|
|
18
|
+
/** Flattened destination parameter ids from all outputs. */
|
|
19
|
+
readonly outputParameterIds: readonly string[];
|
|
20
|
+
}
|
|
@@ -22,6 +22,11 @@ import {
|
|
|
22
22
|
type PlayMotionOptions,
|
|
23
23
|
parseMotion3,
|
|
24
24
|
} from "../motion/index.js";
|
|
25
|
+
import {
|
|
26
|
+
applyPhysics3,
|
|
27
|
+
type Physics3Clip,
|
|
28
|
+
parsePhysics3,
|
|
29
|
+
} from "../physics/index.js";
|
|
25
30
|
import {
|
|
26
31
|
applyPose3Activation,
|
|
27
32
|
type Pose3Clip,
|
|
@@ -72,6 +77,7 @@ export class ActorModelSlot {
|
|
|
72
77
|
weight: number;
|
|
73
78
|
} | null = null;
|
|
74
79
|
#poseClip: Pose3Clip | null = null;
|
|
80
|
+
#physicsClip: Physics3Clip | null = null;
|
|
75
81
|
|
|
76
82
|
constructor(options: ActorModelSlotOptions) {
|
|
77
83
|
this.#assets = options.assets;
|
|
@@ -187,6 +193,30 @@ export class ActorModelSlot {
|
|
|
187
193
|
}
|
|
188
194
|
}
|
|
189
195
|
|
|
196
|
+
async #loadPhysicsClip(): Promise<void> {
|
|
197
|
+
this.#physicsClip = null;
|
|
198
|
+
const physicsPath = this.#model?.settings.physics;
|
|
199
|
+
if (!physicsPath || !this.#lease) return;
|
|
200
|
+
try {
|
|
201
|
+
const json = await this.#lease.resolver.fetchJson(physicsPath);
|
|
202
|
+
this.#physicsClip = parsePhysics3(json);
|
|
203
|
+
} catch {
|
|
204
|
+
this.#physicsClip = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#applyPhysicsLayer(deltaTimeSeconds: number): void {
|
|
209
|
+
if (!this.#physicsClip || !this.#model || !this.#backend) return;
|
|
210
|
+
applyPhysics3(
|
|
211
|
+
this.#physicsClip,
|
|
212
|
+
deltaTimeSeconds,
|
|
213
|
+
this.#paramById,
|
|
214
|
+
(id, value) =>
|
|
215
|
+
this.#backend?.setParameter?.(this.#model!, id, value),
|
|
216
|
+
);
|
|
217
|
+
this.#syncParamCacheFromBackend();
|
|
218
|
+
}
|
|
219
|
+
|
|
190
220
|
#rebuildParamCache(): void {
|
|
191
221
|
this.#paramById.clear();
|
|
192
222
|
this.#paramIndexById.clear();
|
|
@@ -223,8 +253,17 @@ export class ActorModelSlot {
|
|
|
223
253
|
async load(
|
|
224
254
|
source: ModelSource,
|
|
225
255
|
resolver?: AssetResolver,
|
|
256
|
+
options?: { signal?: AbortSignal },
|
|
226
257
|
): Promise<InternalModel> {
|
|
227
258
|
const gen = ++this.#loadGeneration;
|
|
259
|
+
const signal = options?.signal;
|
|
260
|
+
if (signal?.aborted) {
|
|
261
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
262
|
+
}
|
|
263
|
+
const onAbort = () => {
|
|
264
|
+
this.#loadGeneration += 1;
|
|
265
|
+
};
|
|
266
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
228
267
|
this.#report({
|
|
229
268
|
stage: "mounting",
|
|
230
269
|
progress: 0.01,
|
|
@@ -232,15 +271,22 @@ export class ActorModelSlot {
|
|
|
232
271
|
});
|
|
233
272
|
const drawPass = this.ensureDrawPass();
|
|
234
273
|
|
|
235
|
-
|
|
236
|
-
this.#
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
274
|
+
try {
|
|
275
|
+
const lease = await this.#assets.acquire(
|
|
276
|
+
source,
|
|
277
|
+
resolver,
|
|
278
|
+
(p) => this.#report(p),
|
|
279
|
+
{ signal },
|
|
280
|
+
);
|
|
281
|
+
if (gen !== this.#loadGeneration) {
|
|
282
|
+
lease.release();
|
|
283
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
284
|
+
}
|
|
242
285
|
|
|
243
|
-
|
|
286
|
+
return await this.#attachLease(lease, drawPass, gen);
|
|
287
|
+
} finally {
|
|
288
|
+
signal?.removeEventListener("abort", onAbort);
|
|
289
|
+
}
|
|
244
290
|
}
|
|
245
291
|
|
|
246
292
|
async loadAsset(asset: ModelAsset): Promise<InternalModel> {
|
|
@@ -275,6 +321,7 @@ export class ActorModelSlot {
|
|
|
275
321
|
this.#motionPlayer.clear();
|
|
276
322
|
this.#activeExpression = null;
|
|
277
323
|
this.#poseClip = null;
|
|
324
|
+
this.#physicsClip = null;
|
|
278
325
|
this.#releaseLease();
|
|
279
326
|
|
|
280
327
|
const { model, backend } = await lease.createInstance(this.#renderer);
|
|
@@ -294,6 +341,7 @@ export class ActorModelSlot {
|
|
|
294
341
|
this.#backend = backend;
|
|
295
342
|
this.#rebuildParamCache();
|
|
296
343
|
await this.#loadPoseClip();
|
|
344
|
+
await this.#loadPhysicsClip();
|
|
297
345
|
this.#report({
|
|
298
346
|
stage: "ready",
|
|
299
347
|
progress: 1,
|
|
@@ -395,6 +443,7 @@ export class ActorModelSlot {
|
|
|
395
443
|
this.#applyMotionSamples(this.#motionPlayer.update(deltaTimeSeconds));
|
|
396
444
|
this.#tickExpression(deltaTimeSeconds);
|
|
397
445
|
this.#applyExpressionLayer();
|
|
446
|
+
this.#applyPhysicsLayer(deltaTimeSeconds);
|
|
398
447
|
this.#backend.updateModel(this.#model, deltaTimeSeconds);
|
|
399
448
|
return this.#backend.getDrawables(this.#model);
|
|
400
449
|
}
|
|
@@ -446,6 +495,7 @@ export class ActorModelSlot {
|
|
|
446
495
|
this.#motionPlayer.clear();
|
|
447
496
|
this.#activeExpression = null;
|
|
448
497
|
this.#poseClip = null;
|
|
498
|
+
this.#physicsClip = null;
|
|
449
499
|
if (this.#model && this.#backend) {
|
|
450
500
|
this.#backend.destroyModel(this.#model);
|
|
451
501
|
}
|
package/src/stage/actor.ts
CHANGED
|
@@ -53,9 +53,35 @@ export class Live2dActorImpl implements Live2dActor {
|
|
|
53
53
|
this.#slot = new ActorModelSlot({
|
|
54
54
|
assets: shared.assets,
|
|
55
55
|
renderer: shared.renderer,
|
|
56
|
+
onMotionStart: (payload) => this.#onMotionStart?.(payload),
|
|
57
|
+
onMotionFinish: (payload) => this.#onMotionFinish?.(payload),
|
|
56
58
|
});
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
#onMotionStart:
|
|
62
|
+
| ((payload: { group: string; index: number; slot: string }) => void)
|
|
63
|
+
| null = null;
|
|
64
|
+
#onMotionFinish:
|
|
65
|
+
| ((payload: { group: string; index: number; slot: string }) => void)
|
|
66
|
+
| null = null;
|
|
67
|
+
|
|
68
|
+
/** Bridge MotionPlayer lifecycle into Stage/facade event buses. */
|
|
69
|
+
setMotionEventHandlers(handlers: {
|
|
70
|
+
onStart?: (payload: {
|
|
71
|
+
group: string;
|
|
72
|
+
index: number;
|
|
73
|
+
slot: string;
|
|
74
|
+
}) => void;
|
|
75
|
+
onFinish?: (payload: {
|
|
76
|
+
group: string;
|
|
77
|
+
index: number;
|
|
78
|
+
slot: string;
|
|
79
|
+
}) => void;
|
|
80
|
+
}): void {
|
|
81
|
+
this.#onMotionStart = handlers.onStart ?? null;
|
|
82
|
+
this.#onMotionFinish = handlers.onFinish ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
get model(): InternalModel | null {
|
|
60
86
|
return this.#slot.model;
|
|
61
87
|
}
|
|
@@ -106,11 +132,12 @@ export class Live2dActorImpl implements Live2dActor {
|
|
|
106
132
|
async load(
|
|
107
133
|
source: Parameters<Live2dActor["load"]>[0],
|
|
108
134
|
resolver?: Parameters<Live2dActor["load"]>[1],
|
|
135
|
+
options?: { signal?: AbortSignal },
|
|
109
136
|
): Promise<InternalModel> {
|
|
110
137
|
if (this.#destroyed) {
|
|
111
138
|
throw new Error("@doki-land/live2d: actor destroyed");
|
|
112
139
|
}
|
|
113
|
-
return await this.#slot.load(source, resolver);
|
|
140
|
+
return await this.#slot.load(source, resolver, options);
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
async loadAsset(asset: ModelAsset): Promise<InternalModel> {
|
package/src/stage/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export { allocateActorId, Live2dActorImpl } from "./actor.js";
|
|
2
2
|
export { ActorModelSlot } from "./actor-model-slot.js";
|
|
3
3
|
export {
|
|
4
|
-
type
|
|
4
|
+
type CreateLive2dOptions,
|
|
5
5
|
createSingleActorFacade,
|
|
6
|
-
type
|
|
6
|
+
type Live2dRuntime,
|
|
7
7
|
} from "./single-facade.js";
|
|
8
8
|
export { createLive2dStage, Live2dStageImpl } from "./stage.js";
|
|
9
9
|
export {
|
|
@@ -103,8 +103,14 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
103
103
|
source: ModelSource,
|
|
104
104
|
resolver: AssetResolver | undefined,
|
|
105
105
|
onProgress?: (payload: LoadProgress) => void,
|
|
106
|
+
options?: { signal?: AbortSignal },
|
|
106
107
|
): Promise<ModelAssetLease> {
|
|
107
|
-
const entry = await this.#ensureEntry(
|
|
108
|
+
const entry = await this.#ensureEntry(
|
|
109
|
+
source,
|
|
110
|
+
resolver,
|
|
111
|
+
onProgress,
|
|
112
|
+
options,
|
|
113
|
+
);
|
|
108
114
|
entry.refCount += 1;
|
|
109
115
|
return this.#leaseFromEntry(entry);
|
|
110
116
|
}
|
|
@@ -166,6 +172,7 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
166
172
|
source: ModelSource,
|
|
167
173
|
resolver?: AssetResolver,
|
|
168
174
|
onProgress?: (payload: LoadProgress) => void,
|
|
175
|
+
options?: { signal?: AbortSignal },
|
|
169
176
|
): Promise<SharedModelAssetEntry> {
|
|
170
177
|
const key = resolveModelAssetKey(source);
|
|
171
178
|
const existing = this.#entries.get(key);
|
|
@@ -173,7 +180,13 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
173
180
|
|
|
174
181
|
let pending = this.#inFlight.get(key);
|
|
175
182
|
if (!pending) {
|
|
176
|
-
pending = this.#compileEntry(
|
|
183
|
+
pending = this.#compileEntry(
|
|
184
|
+
key,
|
|
185
|
+
source,
|
|
186
|
+
resolver,
|
|
187
|
+
onProgress,
|
|
188
|
+
options,
|
|
189
|
+
);
|
|
177
190
|
this.#inFlight.set(key, pending);
|
|
178
191
|
}
|
|
179
192
|
try {
|
|
@@ -190,8 +203,13 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
190
203
|
source: ModelSource,
|
|
191
204
|
resolver: AssetResolver | undefined,
|
|
192
205
|
onProgress?: (payload: LoadProgress) => void,
|
|
206
|
+
options?: { signal?: AbortSignal },
|
|
193
207
|
): Promise<SharedModelAssetEntry> {
|
|
194
208
|
const notify = (payload: LoadProgress) => onProgress?.(payload);
|
|
209
|
+
const signal = options?.signal;
|
|
210
|
+
if (signal?.aborted) {
|
|
211
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
212
|
+
}
|
|
195
213
|
|
|
196
214
|
notify({
|
|
197
215
|
stage: "resolve",
|
|
@@ -231,23 +249,31 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
231
249
|
progress: 0.05,
|
|
232
250
|
detail: fetchUrl,
|
|
233
251
|
});
|
|
234
|
-
json = await fetchModelJson(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
252
|
+
json = await fetchModelJson(
|
|
253
|
+
fetchUrl,
|
|
254
|
+
(u) => {
|
|
255
|
+
const ratio =
|
|
256
|
+
u.bytesTotal && u.bytesTotal > 0
|
|
257
|
+
? u.bytesLoaded / u.bytesTotal
|
|
258
|
+
: 0;
|
|
259
|
+
notify({
|
|
260
|
+
stage: "settings",
|
|
261
|
+
progress: lerp(0.05, 0.22, ratio),
|
|
262
|
+
detail: fetchUrl,
|
|
263
|
+
bytesLoaded: u.bytesLoaded,
|
|
264
|
+
bytesTotal: u.bytesTotal,
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
{ signal },
|
|
268
|
+
);
|
|
247
269
|
baseUrl = fetchUrl;
|
|
248
270
|
settingsUrl = fetchUrl;
|
|
249
271
|
}
|
|
250
272
|
|
|
273
|
+
if (signal?.aborted) {
|
|
274
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
275
|
+
}
|
|
276
|
+
|
|
251
277
|
const settings = normalizeModelSettings(json, settingsUrl);
|
|
252
278
|
notify({
|
|
253
279
|
stage: "moc",
|
|
@@ -258,6 +284,7 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
258
284
|
const assetResolver =
|
|
259
285
|
resolver ??
|
|
260
286
|
createUrlAssetResolver(baseUrl, {
|
|
287
|
+
signal,
|
|
261
288
|
onBytesProgress: (assetKey, u) => {
|
|
262
289
|
const isMoc = assetKey === settings.moc;
|
|
263
290
|
const ratio =
|
|
@@ -292,6 +319,9 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
292
319
|
});
|
|
293
320
|
|
|
294
321
|
const mocBytes = await assetResolver.fetchBytes(settings.moc);
|
|
322
|
+
if (signal?.aborted) {
|
|
323
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
324
|
+
}
|
|
295
325
|
const sharedCompile = compileSharedModelCompile(settings, mocBytes);
|
|
296
326
|
|
|
297
327
|
const textures: TextureData[] = [];
|
|
@@ -317,6 +347,10 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
317
347
|
);
|
|
318
348
|
}
|
|
319
349
|
|
|
350
|
+
if (signal?.aborted) {
|
|
351
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
352
|
+
}
|
|
353
|
+
|
|
320
354
|
notify({
|
|
321
355
|
stage: "ready",
|
|
322
356
|
progress: 1,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
FrameSnapshot,
|
|
3
|
-
|
|
3
|
+
Live2dSession,
|
|
4
4
|
LoadProgress,
|
|
5
5
|
ModelSource,
|
|
6
6
|
SessionPhase,
|
|
@@ -18,14 +18,14 @@ import { MotionPriority } from "../motion/index.js";
|
|
|
18
18
|
import type { Live2dActorImpl } from "./actor.js";
|
|
19
19
|
import type { Live2dStageImpl } from "./stage.js";
|
|
20
20
|
|
|
21
|
-
export interface
|
|
21
|
+
export interface CreateLive2dOptions {
|
|
22
22
|
backends?: ModelBackend[];
|
|
23
23
|
renderer?: Renderer;
|
|
24
24
|
prefer?: RendererKind[];
|
|
25
25
|
updateMode?: "auto" | "manual";
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export interface
|
|
28
|
+
export interface Live2dRuntime extends Live2dSession {
|
|
29
29
|
readonly renderer: Renderer;
|
|
30
30
|
readonly backends: readonly ModelBackend[];
|
|
31
31
|
readonly stage: Live2dStageImpl;
|
|
@@ -73,7 +73,7 @@ export function createSingleActorFacade(
|
|
|
73
73
|
stage: Live2dStageImpl,
|
|
74
74
|
actor: Live2dActorImpl,
|
|
75
75
|
backends: readonly ModelBackend[],
|
|
76
|
-
):
|
|
76
|
+
): Live2dRuntime {
|
|
77
77
|
const events = new EventEmitter();
|
|
78
78
|
let canvas: HTMLCanvasElement | null = null;
|
|
79
79
|
let phase: SessionPhase = "idle";
|
|
@@ -92,7 +92,7 @@ export function createSingleActorFacade(
|
|
|
92
92
|
generation,
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
-
const runtime:
|
|
95
|
+
const runtime: Live2dRuntime = {
|
|
96
96
|
events,
|
|
97
97
|
backends,
|
|
98
98
|
renderer: stage.renderer,
|
|
@@ -117,10 +117,10 @@ export function createSingleActorFacade(
|
|
|
117
117
|
},
|
|
118
118
|
);
|
|
119
119
|
},
|
|
120
|
-
async loadModel(source: ModelSource, resolver) {
|
|
120
|
+
async loadModel(source: ModelSource, resolver, options) {
|
|
121
121
|
setPhase("loading");
|
|
122
122
|
try {
|
|
123
|
-
const model = await actor.load(source, resolver);
|
|
123
|
+
const model = await actor.load(source, resolver, options);
|
|
124
124
|
lastError = null;
|
|
125
125
|
setPhase("live");
|
|
126
126
|
events.emit("ready", { modelId: model.id });
|
|
@@ -234,6 +234,11 @@ export function createSingleActorFacade(
|
|
|
234
234
|
},
|
|
235
235
|
};
|
|
236
236
|
|
|
237
|
+
actor.setMotionEventHandlers({
|
|
238
|
+
onStart: (payload) => events.emit("motion:start", payload),
|
|
239
|
+
onFinish: (payload) => events.emit("motion:finish", payload),
|
|
240
|
+
});
|
|
241
|
+
|
|
237
242
|
return runtime;
|
|
238
243
|
}
|
|
239
244
|
|