@doki-land/live2d 0.0.13 → 0.0.15
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 +180 -1
- package/dist/index.d.ts +72 -7
- package/dist/index.js +407 -208
- package/package.json +9 -7
- package/src/index.ts +4 -0
- package/src/stage/actor-model-slot.ts +63 -167
- package/src/stage/actor.ts +42 -7
- package/src/stage/model-asset-key.ts +19 -0
- package/src/stage/model-asset-registry.ts +337 -0
- package/src/stage/single-facade.ts +5 -5
- package/src/stage/stage.ts +27 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssetResolver,
|
|
3
|
+
InternalModel,
|
|
4
|
+
Live2dStageAssets,
|
|
5
|
+
LoadProgress,
|
|
6
|
+
ModelAsset,
|
|
7
|
+
ModelSettings,
|
|
8
|
+
ModelSource,
|
|
9
|
+
} from "@doki-land/live2d-core";
|
|
10
|
+
import { modelSourceUrl } from "@doki-land/live2d-core";
|
|
11
|
+
import {
|
|
12
|
+
createUrlAssetResolver,
|
|
13
|
+
fetchModelJson,
|
|
14
|
+
normalizeModelSettings,
|
|
15
|
+
resolveModelSourceUrl,
|
|
16
|
+
} from "@doki-land/live2d-loader";
|
|
17
|
+
import type {
|
|
18
|
+
ModelBackend,
|
|
19
|
+
Renderer,
|
|
20
|
+
TextureData,
|
|
21
|
+
} from "@doki-land/live2d-renderer";
|
|
22
|
+
import {
|
|
23
|
+
compileSharedModelCompile,
|
|
24
|
+
selectModelBackend,
|
|
25
|
+
} from "@doki-land/live2d-renderer";
|
|
26
|
+
import { loadTextureData, releaseTextureData } from "../load-textures.js";
|
|
27
|
+
import type { Motion3Clip } from "../motion/index.js";
|
|
28
|
+
import { resolveModelAssetKey } from "./model-asset-key.js";
|
|
29
|
+
|
|
30
|
+
function lerp(a: number, b: number, t: number): number {
|
|
31
|
+
return a + (b - a) * Math.min(1, Math.max(0, t));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @internal */
|
|
35
|
+
export class ModelAssetHandle implements ModelAsset {
|
|
36
|
+
readonly #entry: SharedModelAssetEntry;
|
|
37
|
+
|
|
38
|
+
constructor(entry: SharedModelAssetEntry) {
|
|
39
|
+
this.#entry = entry;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get key(): string {
|
|
43
|
+
return this.#entry.key;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get settings(): ModelSettings {
|
|
47
|
+
return this.#entry.settings;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @internal */
|
|
51
|
+
get entry(): SharedModelAssetEntry {
|
|
52
|
+
return this.#entry;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @internal */
|
|
57
|
+
export interface SharedModelAssetEntry {
|
|
58
|
+
readonly key: string;
|
|
59
|
+
readonly settings: ModelSettings;
|
|
60
|
+
readonly backend: ModelBackend;
|
|
61
|
+
readonly resolver: AssetResolver;
|
|
62
|
+
readonly sharedCompile: ReturnType<typeof compileSharedModelCompile>;
|
|
63
|
+
readonly textures: TextureData[];
|
|
64
|
+
readonly motionCache: Map<string, Motion3Clip>;
|
|
65
|
+
refCount: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Lease held by one actor slot while a model is attached. */
|
|
69
|
+
export interface ModelAssetLease {
|
|
70
|
+
readonly asset: ModelAssetHandle;
|
|
71
|
+
readonly motionCache: Map<string, Motion3Clip>;
|
|
72
|
+
readonly resolver: AssetResolver;
|
|
73
|
+
readonly textures: readonly TextureData[];
|
|
74
|
+
createInstance(renderer: Renderer): Promise<{
|
|
75
|
+
model: InternalModel;
|
|
76
|
+
backend: ModelBackend;
|
|
77
|
+
}>;
|
|
78
|
+
release(): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ModelAssetRegistryOptions {
|
|
82
|
+
backends: readonly ModelBackend[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class ModelAssetRegistry implements Live2dStageAssets {
|
|
86
|
+
readonly #backends: readonly ModelBackend[];
|
|
87
|
+
readonly #entries = new Map<string, SharedModelAssetEntry>();
|
|
88
|
+
readonly #inFlight = new Map<string, Promise<SharedModelAssetEntry>>();
|
|
89
|
+
|
|
90
|
+
constructor(options: ModelAssetRegistryOptions) {
|
|
91
|
+
this.#backends = options.backends;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async load(
|
|
95
|
+
source: ModelSource,
|
|
96
|
+
resolver?: AssetResolver,
|
|
97
|
+
): Promise<ModelAsset> {
|
|
98
|
+
const entry = await this.#ensureEntry(source, resolver);
|
|
99
|
+
return new ModelAssetHandle(entry);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async acquire(
|
|
103
|
+
source: ModelSource,
|
|
104
|
+
resolver: AssetResolver | undefined,
|
|
105
|
+
onProgress?: (payload: LoadProgress) => void,
|
|
106
|
+
): Promise<ModelAssetLease> {
|
|
107
|
+
const entry = await this.#ensureEntry(source, resolver, onProgress);
|
|
108
|
+
entry.refCount += 1;
|
|
109
|
+
return this.#leaseFromEntry(entry);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
acquireExisting(asset: ModelAsset): ModelAssetLease {
|
|
113
|
+
const handle = asset as ModelAssetHandle;
|
|
114
|
+
const entry = handle.entry;
|
|
115
|
+
if (
|
|
116
|
+
!this.#entries.has(entry.key) ||
|
|
117
|
+
this.#entries.get(entry.key) !== entry
|
|
118
|
+
) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
"@doki-land/live2d: ModelAsset does not belong to this stage",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
entry.refCount += 1;
|
|
124
|
+
return this.#leaseFromEntry(entry);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
destroy(): void {
|
|
128
|
+
this.#inFlight.clear();
|
|
129
|
+
for (const entry of this.#entries.values()) {
|
|
130
|
+
releaseTextureData(entry.textures);
|
|
131
|
+
entry.motionCache.clear();
|
|
132
|
+
}
|
|
133
|
+
this.#entries.clear();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#leaseFromEntry(entry: SharedModelAssetEntry): ModelAssetLease {
|
|
137
|
+
let released = false;
|
|
138
|
+
return {
|
|
139
|
+
asset: new ModelAssetHandle(entry),
|
|
140
|
+
motionCache: entry.motionCache,
|
|
141
|
+
resolver: entry.resolver,
|
|
142
|
+
textures: entry.textures,
|
|
143
|
+
createInstance: async (renderer) => {
|
|
144
|
+
const model = await entry.backend.createModel(entry.settings, {
|
|
145
|
+
renderer,
|
|
146
|
+
resolver: entry.resolver,
|
|
147
|
+
sharedCompile: entry.sharedCompile,
|
|
148
|
+
});
|
|
149
|
+
return { model, backend: entry.backend };
|
|
150
|
+
},
|
|
151
|
+
release: () => {
|
|
152
|
+
if (released) return;
|
|
153
|
+
released = true;
|
|
154
|
+
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
155
|
+
if (entry.refCount === 0) {
|
|
156
|
+
releaseTextureData(entry.textures);
|
|
157
|
+
entry.textures.length = 0;
|
|
158
|
+
entry.motionCache.clear();
|
|
159
|
+
this.#entries.delete(entry.key);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async #ensureEntry(
|
|
166
|
+
source: ModelSource,
|
|
167
|
+
resolver?: AssetResolver,
|
|
168
|
+
onProgress?: (payload: LoadProgress) => void,
|
|
169
|
+
): Promise<SharedModelAssetEntry> {
|
|
170
|
+
const key = resolveModelAssetKey(source);
|
|
171
|
+
const existing = this.#entries.get(key);
|
|
172
|
+
if (existing) return existing;
|
|
173
|
+
|
|
174
|
+
let pending = this.#inFlight.get(key);
|
|
175
|
+
if (!pending) {
|
|
176
|
+
pending = this.#compileEntry(key, source, resolver, onProgress);
|
|
177
|
+
this.#inFlight.set(key, pending);
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const entry = await pending;
|
|
181
|
+
this.#entries.set(key, entry);
|
|
182
|
+
return entry;
|
|
183
|
+
} finally {
|
|
184
|
+
this.#inFlight.delete(key);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async #compileEntry(
|
|
189
|
+
key: string,
|
|
190
|
+
source: ModelSource,
|
|
191
|
+
resolver: AssetResolver | undefined,
|
|
192
|
+
onProgress?: (payload: LoadProgress) => void,
|
|
193
|
+
): Promise<SharedModelAssetEntry> {
|
|
194
|
+
const notify = (payload: LoadProgress) => onProgress?.(payload);
|
|
195
|
+
|
|
196
|
+
notify({
|
|
197
|
+
stage: "resolve",
|
|
198
|
+
progress: 0.02,
|
|
199
|
+
detail: "resolve source",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
let json: unknown;
|
|
203
|
+
let baseUrl: string;
|
|
204
|
+
let settingsUrl: string;
|
|
205
|
+
|
|
206
|
+
if (typeof source === "object" && source.kind === "json") {
|
|
207
|
+
json = source.json;
|
|
208
|
+
baseUrl = source.baseUrl;
|
|
209
|
+
settingsUrl = source.baseUrl;
|
|
210
|
+
notify({
|
|
211
|
+
stage: "settings",
|
|
212
|
+
progress: 0.2,
|
|
213
|
+
detail: "inline settings",
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
const raw =
|
|
217
|
+
typeof source === "string"
|
|
218
|
+
? source
|
|
219
|
+
: source.kind === "npm"
|
|
220
|
+
? modelSourceUrl(source)
|
|
221
|
+
: source.url;
|
|
222
|
+
const cdnBase =
|
|
223
|
+
typeof source === "object" && source.kind === "npm"
|
|
224
|
+
? source.cdnBase
|
|
225
|
+
: undefined;
|
|
226
|
+
const fetchUrl = resolveModelSourceUrl(raw, {
|
|
227
|
+
npmCdnBase: cdnBase,
|
|
228
|
+
});
|
|
229
|
+
notify({
|
|
230
|
+
stage: "settings",
|
|
231
|
+
progress: 0.05,
|
|
232
|
+
detail: fetchUrl,
|
|
233
|
+
});
|
|
234
|
+
json = await fetchModelJson(fetchUrl, (u) => {
|
|
235
|
+
const ratio =
|
|
236
|
+
u.bytesTotal && u.bytesTotal > 0
|
|
237
|
+
? u.bytesLoaded / u.bytesTotal
|
|
238
|
+
: 0;
|
|
239
|
+
notify({
|
|
240
|
+
stage: "settings",
|
|
241
|
+
progress: lerp(0.05, 0.22, ratio),
|
|
242
|
+
detail: fetchUrl,
|
|
243
|
+
bytesLoaded: u.bytesLoaded,
|
|
244
|
+
bytesTotal: u.bytesTotal,
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
baseUrl = fetchUrl;
|
|
248
|
+
settingsUrl = fetchUrl;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const settings = normalizeModelSettings(json, settingsUrl);
|
|
252
|
+
notify({
|
|
253
|
+
stage: "moc",
|
|
254
|
+
progress: 0.25,
|
|
255
|
+
detail: settings.moc,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const assetResolver =
|
|
259
|
+
resolver ??
|
|
260
|
+
createUrlAssetResolver(baseUrl, {
|
|
261
|
+
onBytesProgress: (assetKey, u) => {
|
|
262
|
+
const isMoc = assetKey === settings.moc;
|
|
263
|
+
const ratio =
|
|
264
|
+
u.bytesTotal && u.bytesTotal > 0
|
|
265
|
+
? u.bytesLoaded / u.bytesTotal
|
|
266
|
+
: 0;
|
|
267
|
+
if (isMoc) {
|
|
268
|
+
notify({
|
|
269
|
+
stage: "moc",
|
|
270
|
+
progress: lerp(0.25, 0.8, ratio),
|
|
271
|
+
detail: assetKey,
|
|
272
|
+
bytesLoaded: u.bytesLoaded,
|
|
273
|
+
bytesTotal: u.bytesTotal,
|
|
274
|
+
});
|
|
275
|
+
} else {
|
|
276
|
+
notify({
|
|
277
|
+
stage: "textures",
|
|
278
|
+
progress: lerp(0.8, 0.9, ratio),
|
|
279
|
+
detail: assetKey,
|
|
280
|
+
bytesLoaded: u.bytesLoaded,
|
|
281
|
+
bytesTotal: u.bytesTotal,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const backend = selectModelBackend([...this.#backends], json);
|
|
288
|
+
notify({
|
|
289
|
+
stage: "decode",
|
|
290
|
+
progress: 0.85,
|
|
291
|
+
detail: `decode ${settings.format}`,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const mocBytes = await assetResolver.fetchBytes(settings.moc);
|
|
295
|
+
const sharedCompile = compileSharedModelCompile(settings, mocBytes);
|
|
296
|
+
|
|
297
|
+
const textures: TextureData[] = [];
|
|
298
|
+
if (settings.textures.length > 0) {
|
|
299
|
+
notify({
|
|
300
|
+
stage: "textures",
|
|
301
|
+
progress: 0.88,
|
|
302
|
+
detail: `${settings.textures.length} textures`,
|
|
303
|
+
});
|
|
304
|
+
textures.push(
|
|
305
|
+
...(await loadTextureData(assetResolver, settings.textures, {
|
|
306
|
+
onProgress: (u) => {
|
|
307
|
+
const ratio = u.total > 0 ? (u.index + 1) / u.total : 1;
|
|
308
|
+
notify({
|
|
309
|
+
stage: "textures",
|
|
310
|
+
progress: lerp(0.88, 0.96, ratio),
|
|
311
|
+
detail: u.key,
|
|
312
|
+
bytesLoaded: u.bytesLoaded,
|
|
313
|
+
bytesTotal: u.bytesTotal,
|
|
314
|
+
});
|
|
315
|
+
},
|
|
316
|
+
})),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
notify({
|
|
321
|
+
stage: "ready",
|
|
322
|
+
progress: 1,
|
|
323
|
+
detail: settings.name ?? key,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
key,
|
|
328
|
+
settings,
|
|
329
|
+
backend,
|
|
330
|
+
resolver: assetResolver,
|
|
331
|
+
sharedCompile,
|
|
332
|
+
textures,
|
|
333
|
+
motionCache: new Map(),
|
|
334
|
+
refCount: 0,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -139,19 +139,19 @@ export function createSingleActorFacade(
|
|
|
139
139
|
return hit.area;
|
|
140
140
|
},
|
|
141
141
|
listParameters() {
|
|
142
|
-
return actor.
|
|
142
|
+
return actor.listParameters();
|
|
143
143
|
},
|
|
144
144
|
listMotionGroups() {
|
|
145
|
-
return actor.
|
|
145
|
+
return actor.listMotionGroups();
|
|
146
146
|
},
|
|
147
147
|
playMotion(group, index, options) {
|
|
148
|
-
return actor.
|
|
148
|
+
return actor.playMotion(group, index, options);
|
|
149
149
|
},
|
|
150
150
|
stopMotion(opts) {
|
|
151
|
-
actor.
|
|
151
|
+
actor.stopMotion(opts);
|
|
152
152
|
},
|
|
153
153
|
listPlayingMotions() {
|
|
154
|
-
return actor.
|
|
154
|
+
return actor.listPlayingMotions();
|
|
155
155
|
},
|
|
156
156
|
async capturePng(opts = {}) {
|
|
157
157
|
if (!canvas) {
|
package/src/stage/stage.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
createRenderer,
|
|
19
19
|
} from "@doki-land/live2d-renderer";
|
|
20
20
|
import { allocateActorId, Live2dActorImpl } from "./actor.js";
|
|
21
|
+
import { ModelAssetRegistry } from "./model-asset-registry.js";
|
|
21
22
|
import {
|
|
22
23
|
clientToStage,
|
|
23
24
|
compareActorsForDraw,
|
|
@@ -36,6 +37,7 @@ type PointerListener = (event: StagePointerEvent) => void;
|
|
|
36
37
|
export class Live2dStageImpl implements Live2dStage {
|
|
37
38
|
readonly #backends: readonly ModelBackend[];
|
|
38
39
|
readonly #renderer: Renderer;
|
|
40
|
+
readonly #assets: ModelAssetRegistry;
|
|
39
41
|
readonly #updateMode: "auto" | "manual";
|
|
40
42
|
readonly #actors = new Map<string, Live2dActorImpl>();
|
|
41
43
|
readonly #definedLayers: string[] = [
|
|
@@ -76,9 +78,14 @@ export class Live2dStageImpl implements Live2dStage {
|
|
|
76
78
|
];
|
|
77
79
|
this.#renderer =
|
|
78
80
|
options.renderer ?? createRenderer({ prefer: options.prefer });
|
|
81
|
+
this.#assets = new ModelAssetRegistry({ backends: this.#backends });
|
|
79
82
|
this.#updateMode = options.updateMode ?? "auto";
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
get assets(): ModelAssetRegistry {
|
|
86
|
+
return this.#assets;
|
|
87
|
+
}
|
|
88
|
+
|
|
82
89
|
get actors(): readonly Live2dActor[] {
|
|
83
90
|
return [...this.#actors.values()];
|
|
84
91
|
}
|
|
@@ -117,7 +124,7 @@ export class Live2dStageImpl implements Live2dStage {
|
|
|
117
124
|
const actor = new Live2dActorImpl(options, {
|
|
118
125
|
id,
|
|
119
126
|
creationIndex,
|
|
120
|
-
|
|
127
|
+
assets: this.#assets,
|
|
121
128
|
renderer: this.#renderer,
|
|
122
129
|
});
|
|
123
130
|
this.#actors.set(id, actor);
|
|
@@ -127,6 +134,24 @@ export class Live2dStageImpl implements Live2dStage {
|
|
|
127
134
|
return actor;
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
getActor(id: string): Live2dActor | null {
|
|
138
|
+
return this.#actors.get(id) ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
resize(width?: number, height?: number): void {
|
|
142
|
+
if (!this.#canvas || this.#destroyed) return;
|
|
143
|
+
const cssW = width ?? this.#canvas.clientWidth;
|
|
144
|
+
const cssH = height ?? this.#canvas.clientHeight;
|
|
145
|
+
if (cssW <= 0 || cssH <= 0) return;
|
|
146
|
+
const dpr =
|
|
147
|
+
typeof globalThis.devicePixelRatio === "number"
|
|
148
|
+
? globalThis.devicePixelRatio
|
|
149
|
+
: 1;
|
|
150
|
+
this.#canvas.width = Math.round(cssW * dpr);
|
|
151
|
+
this.#canvas.height = Math.round(cssH * dpr);
|
|
152
|
+
this.#renderer.resize(cssW, cssH);
|
|
153
|
+
}
|
|
154
|
+
|
|
130
155
|
removeActor(actorOrId: Live2dActor | string): void {
|
|
131
156
|
const id = typeof actorOrId === "string" ? actorOrId : actorOrId.id;
|
|
132
157
|
const actor = this.#actors.get(id);
|
|
@@ -256,6 +281,7 @@ export class Live2dStageImpl implements Live2dStage {
|
|
|
256
281
|
actor.destroy();
|
|
257
282
|
}
|
|
258
283
|
this.#actors.clear();
|
|
284
|
+
this.#assets.destroy();
|
|
259
285
|
this.#renderer.destroy();
|
|
260
286
|
this.#canvas = null;
|
|
261
287
|
this.#initPromise = null;
|