@doki-land/live2d 0.0.14 → 0.0.16
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 +189 -1
- package/dist/index.d.ts +55 -7
- package/dist/index.js +370 -203
- package/package.json +9 -7
- package/src/index.ts +3 -0
- package/src/stage/actor-model-slot.ts +63 -167
- package/src/stage/actor.ts +12 -7
- package/src/stage/model-asset-key.ts +19 -0
- package/src/stage/model-asset-registry.ts +337 -0
- package/src/stage/stage.ts +9 -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
|
+
}
|
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);
|
|
@@ -274,6 +281,7 @@ export class Live2dStageImpl implements Live2dStage {
|
|
|
274
281
|
actor.destroy();
|
|
275
282
|
}
|
|
276
283
|
this.#actors.clear();
|
|
284
|
+
this.#assets.destroy();
|
|
277
285
|
this.#renderer.destroy();
|
|
278
286
|
this.#canvas = null;
|
|
279
287
|
this.#initPromise = null;
|