@doki-land/live2d-renderer 0.0.18 → 0.0.20

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/src/moc/moc3.ts CHANGED
@@ -10,7 +10,7 @@ import { detectModelSettingsFormat } from "@doki-land/live2d-core";
10
10
  import { isCpuProgramBytes, parseCpuProgram } from "../cpu/cpu-program.js";
11
11
  import {
12
12
  createModelInstance,
13
- evaluateFrame,
13
+ evaluateFrameInto,
14
14
  setParameterValue,
15
15
  } from "../cpu/evaluate.js";
16
16
  import type {
@@ -21,13 +21,34 @@ import type {
21
21
  import type { BlendMode, DrawableMesh } from "../types.js";
22
22
  import { cascadedPartOpacity, readMoc3PartTables } from "./moc3-parts.js";
23
23
  import { type Moc3Document, parseMoc3Document } from "./moc3-reader.js";
24
- import { moc3DocumentToProgram } from "./moc3-to-program.js";
24
+ import {
25
+ evaluateMoc3PoseInto,
26
+ moc3ArtMeshIndicesInProgramOrder,
27
+ moc3DocumentToProgram,
28
+ } from "./moc3-to-program.js";
25
29
 
26
- function toDrawableMesh(d: FrameDrawable): DrawableMesh {
27
- return {
30
+ interface Moc3State {
31
+ /** Present for binary MOC3 input; null for cpu-program fixtures. */
32
+ doc: Moc3Document | null;
33
+ instance: ModelInstance;
34
+ meshes: DrawableMesh[];
35
+ drawView: DrawableMesh[];
36
+ /** art-mesh index → mesh (binary path); empty for cpu-program. */
37
+ byArtMesh: Map<number, DrawableMesh>;
38
+ poseOpacity: Float32Array;
39
+ poseDirty: boolean;
40
+ partOpacity: Map<string, number>;
41
+ bindings: ParameterBinding[];
42
+ paramIndexById: Map<string, number>;
43
+ }
44
+
45
+ const stateByModel = new WeakMap<InternalModel, Moc3State>();
46
+
47
+ function allocateMeshesFromProgram(program: ModelProgram): DrawableMesh[] {
48
+ return program.drawables.map((d) => ({
28
49
  index: d.index,
29
50
  textureIndex: d.textureIndex,
30
- vertexPositions: d.positions,
51
+ vertexPositions: new Float32Array(d.positions),
31
52
  uvs: d.uvs,
32
53
  indices: d.indices,
33
54
  opacity: d.opacity,
@@ -37,49 +58,96 @@ function toDrawableMesh(d: FrameDrawable): DrawableMesh {
37
58
  dynamicFlag: true,
38
59
  maskIndices: [...d.maskIndices],
39
60
  visible: d.visible,
40
- };
61
+ }));
62
+ }
63
+
64
+ function buildBindings(instance: ModelInstance): {
65
+ bindings: ParameterBinding[];
66
+ paramIndexById: Map<string, number>;
67
+ } {
68
+ const bindings: ParameterBinding[] = [];
69
+ const paramIndexById = new Map<string, number>();
70
+ for (let i = 0; i < instance.program.parameters.length; i++) {
71
+ const p = instance.program.parameters[i]!;
72
+ bindings.push({
73
+ id: p.id,
74
+ min: p.min,
75
+ max: p.max,
76
+ defaultValue: p.defaultValue,
77
+ value: instance.parameterValues[i] ?? p.defaultValue,
78
+ });
79
+ paramIndexById.set(p.id, i);
80
+ }
81
+ return { bindings, paramIndexById };
41
82
  }
42
83
 
43
- function paramFingerprint(values: ArrayLike<number>): string {
44
- let s = "";
45
- for (let i = 0; i < values.length; i++) {
46
- s += `${values[i]?.toFixed(5)},`;
84
+ function syncBindingValues(state: Moc3State): void {
85
+ for (let i = 0; i < state.bindings.length; i++) {
86
+ const b = state.bindings[i]!;
87
+ (b as { value: number }).value =
88
+ state.instance.parameterValues[i] ?? b.defaultValue;
47
89
  }
48
- return s;
49
90
  }
50
91
 
51
- interface Moc3State {
52
- /** Present for binary MOC3 input; null for cpu-program fixtures. */
53
- doc: Moc3Document | null;
54
- instance: ModelInstance;
55
- lastFrame: FrameSnapshot | null;
56
- bakedFingerprint: string;
57
- /** Runtime PartOpacity overrides (id → 0..1). */
58
- partOpacity: Map<string, number>;
92
+ function refreshDrawView(state: Moc3State): void {
93
+ const view = state.drawView;
94
+ view.length = 0;
95
+ for (const m of state.meshes) view.push(m);
96
+ view.sort((a, b) => a.renderOrder - b.renderOrder || a.index - b.index);
59
97
  }
60
98
 
61
- const stateByModel = new WeakMap<InternalModel, Moc3State>();
99
+ function applyPartOpacity(state: Moc3State): void {
100
+ const tables = state.doc ? readMoc3PartTables(state.doc) : null;
101
+ for (const mesh of state.meshes) {
102
+ const base = state.poseOpacity[mesh.index] ?? mesh.opacity;
103
+ if (!tables || state.partOpacity.size === 0) {
104
+ mesh.opacity = base;
105
+ continue;
106
+ }
107
+ mesh.opacity =
108
+ base * cascadedPartOpacity(tables, mesh.index, state.partOpacity);
109
+ }
110
+ }
62
111
 
63
112
  function bakePose(state: Moc3State): void {
64
- if (!state.doc) {
65
- state.lastFrame = evaluateFrame(state.instance);
66
- return;
67
- }
68
-
69
- const values = Float32Array.from(state.instance.parameterValues);
70
- const timeSeconds = state.instance.timeSeconds;
71
- const fp = paramFingerprint(values);
72
- if (fp === state.bakedFingerprint && state.lastFrame) return;
73
-
74
- const program = moc3DocumentToProgram(state.doc, {
75
- getParamByIndex: (i) => values[i] ?? 0,
76
- });
77
- const next = createModelInstance(program);
78
- next.parameterValues.set(values);
79
- next.timeSeconds = timeSeconds;
80
- state.instance = next;
81
- state.bakedFingerprint = fp;
82
- state.lastFrame = evaluateFrame(next);
113
+ if (!state.poseDirty) return;
114
+ if (state.doc) {
115
+ const values = state.instance.parameterValues;
116
+ evaluateMoc3PoseInto(
117
+ state.doc,
118
+ (i) => values[i] ?? 0,
119
+ state.byArtMesh,
120
+ state.poseOpacity,
121
+ );
122
+ } else {
123
+ evaluateFrameInto(state.instance, state.meshes, state.poseOpacity);
124
+ }
125
+ refreshDrawView(state);
126
+ syncBindingValues(state);
127
+ state.poseDirty = false;
128
+ }
129
+
130
+ function frameFromMeshes(
131
+ meshes: readonly DrawableMesh[],
132
+ timeSeconds: number,
133
+ ): FrameSnapshot {
134
+ const drawables: FrameDrawable[] = [];
135
+ for (const m of meshes) {
136
+ drawables.push({
137
+ index: m.index,
138
+ textureIndex: m.textureIndex,
139
+ positions: m.vertexPositions,
140
+ uvs: m.uvs,
141
+ indices: m.indices,
142
+ opacity: m.opacity,
143
+ blendMode: m.blendMode,
144
+ renderOrder: m.renderOrder,
145
+ visible: m.visible,
146
+ invertedMask: m.invertedMask,
147
+ maskIndices: m.maskIndices,
148
+ });
149
+ }
150
+ return { timeSeconds, drawables };
83
151
  }
84
152
 
85
153
  /** moc3 model backend for binary `.moc3` input or CPU `.program.json` fixtures. */
@@ -121,18 +189,39 @@ export class Moc3Backend implements ModelBackend {
121
189
  }
122
190
 
123
191
  const instance = createModelInstance(program);
192
+ const meshes = allocateMeshesFromProgram(program);
193
+ const byArtMesh = new Map<number, DrawableMesh>();
194
+ if (doc) {
195
+ const artOrder = moc3ArtMeshIndicesInProgramOrder(doc);
196
+ for (let i = 0; i < artOrder.length; i++) {
197
+ const art = artOrder[i]!;
198
+ const mesh = meshes[i];
199
+ if (mesh) byArtMesh.set(art, mesh);
200
+ }
201
+ }
202
+ const poseOpacity = new Float32Array(meshes.length);
203
+ for (const m of meshes) poseOpacity[m.index] = m.opacity;
204
+ const { bindings, paramIndexById } = buildBindings(instance);
124
205
  const model: InternalModel = {
125
206
  id: settings.name ?? settings.url,
126
207
  settings,
127
208
  format: "moc3",
128
209
  };
129
- stateByModel.set(model, {
210
+ const state: Moc3State = {
130
211
  doc,
131
212
  instance,
132
- lastFrame: null,
133
- bakedFingerprint: paramFingerprint(instance.parameterValues),
213
+ meshes,
214
+ drawView: [],
215
+ byArtMesh,
216
+ poseOpacity,
217
+ poseDirty: true,
134
218
  partOpacity: new Map(),
135
- });
219
+ bindings,
220
+ paramIndexById,
221
+ };
222
+ bakePose(state);
223
+ applyPartOpacity(state);
224
+ stateByModel.set(model, state);
136
225
  return model;
137
226
  }
138
227
 
@@ -141,54 +230,39 @@ export class Moc3Backend implements ModelBackend {
141
230
  if (!state) return;
142
231
  state.instance.timeSeconds += deltaTimeSeconds;
143
232
  bakePose(state);
144
- if (state.lastFrame) {
145
- state.lastFrame = {
146
- ...state.lastFrame,
147
- timeSeconds: state.instance.timeSeconds,
148
- };
149
- }
233
+ applyPartOpacity(state);
150
234
  }
151
235
 
152
236
  getDrawables(model: InternalModel): DrawableMesh[] {
153
237
  const state = stateByModel.get(model);
154
238
  if (!state) return [];
155
239
  bakePose(state);
156
- const frame = state.lastFrame ?? evaluateFrame(state.instance);
157
- state.lastFrame = frame;
158
- const tables = state.doc ? readMoc3PartTables(state.doc) : null;
159
- return frame.drawables.map((d) => {
160
- const mesh = toDrawableMesh(d);
161
- if (!tables || state.partOpacity.size === 0) return mesh;
162
- const mul = cascadedPartOpacity(tables, d.index, state.partOpacity);
163
- return { ...mesh, opacity: mesh.opacity * mul };
164
- });
240
+ applyPartOpacity(state);
241
+ return state.drawView;
165
242
  }
166
243
 
167
244
  captureFrame(model: InternalModel): FrameSnapshot | null {
168
245
  const state = stateByModel.get(model);
169
246
  if (!state) return null;
170
247
  bakePose(state);
171
- const frame = state.lastFrame ?? evaluateFrame(state.instance);
172
- state.lastFrame = frame;
173
- const tables = state.doc ? readMoc3PartTables(state.doc) : null;
174
- if (!tables || state.partOpacity.size === 0) return frame;
175
- return {
176
- ...frame,
177
- drawables: frame.drawables.map((d) => ({
178
- ...d,
179
- opacity:
180
- d.opacity *
181
- cascadedPartOpacity(tables, d.index, state.partOpacity),
182
- })),
183
- };
248
+ applyPartOpacity(state);
249
+ return frameFromMeshes(state.drawView, state.instance.timeSeconds);
184
250
  }
185
251
 
186
252
  setParameter(model: InternalModel, id: string, value: number): void {
187
253
  const state = stateByModel.get(model);
188
254
  if (!state) return;
189
255
  setParameterValue(state.instance, id, value);
190
- state.lastFrame = null;
191
- state.bakedFingerprint = "";
256
+ const index = state.paramIndexById.get(id);
257
+ if (index !== undefined) {
258
+ const binding = state.bindings[index];
259
+ if (binding) {
260
+ (binding as { value: number }).value =
261
+ state.instance.parameterValues[index] ??
262
+ binding.defaultValue;
263
+ }
264
+ }
265
+ state.poseDirty = true;
192
266
  }
193
267
 
194
268
  setPartOpacity(model: InternalModel, id: string, value: number): void {
@@ -196,19 +270,20 @@ export class Moc3Backend implements ModelBackend {
196
270
  if (!state) return;
197
271
  const v = Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 1;
198
272
  state.partOpacity.set(id, v);
199
- // Opacity is applied in getDrawables; no need to rebake deform.
273
+ }
274
+
275
+ resolveParameter(model: InternalModel, id: string): number | undefined {
276
+ const state = stateByModel.get(model);
277
+ if (!state) return undefined;
278
+ return state.paramIndexById.get(id);
200
279
  }
201
280
 
202
281
  listParameters(model: InternalModel): readonly ParameterBinding[] {
203
282
  const state = stateByModel.get(model);
204
283
  if (!state) return [];
205
- return state.instance.program.parameters.map((p, i) => ({
206
- id: p.id,
207
- min: p.min,
208
- max: p.max,
209
- defaultValue: p.defaultValue,
210
- value: state.instance.parameterValues[i] ?? p.defaultValue,
211
- }));
284
+ bakePose(state);
285
+ syncBindingValues(state);
286
+ return state.bindings;
212
287
  }
213
288
 
214
289
  hitTest(_model: InternalModel, _x: number, _y: number): string | null {
@@ -218,6 +293,10 @@ export class Moc3Backend implements ModelBackend {
218
293
  destroyModel(model: InternalModel): void {
219
294
  stateByModel.delete(model);
220
295
  }
296
+
297
+ getResidentInstance(model: InternalModel): ModelInstance | null {
298
+ return stateByModel.get(model)?.instance ?? null;
299
+ }
221
300
  }
222
301
 
223
302
  export function createMoc3Backend(): Moc3Backend {
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Resident clipping / mask atlas plan.
3
+ *
4
+ * Topology (maskIndices / invert / packing options) is compiled once; per frame
5
+ * only `modelBounds` is refreshed from current vertex positions.
6
+ */
7
+
8
+ import type { DrawableMesh } from "../types.js";
9
+ import {
10
+ type ClippingDrawableRef,
11
+ type ClippingPartition,
12
+ calcClippedDrawableBounds,
13
+ type LaidOutClippingContext,
14
+ type MaskAtlasOptions,
15
+ type MaskBoundsMeshRef,
16
+ type MaskLayoutRect,
17
+ partitionForClipping,
18
+ } from "./clipping.js";
19
+
20
+ /** Stable key for mask topology (ignores vertex positions / opacity). */
21
+ export function clippingTopologyKey(
22
+ drawables: readonly ClippingDrawableRef[],
23
+ ): string {
24
+ let key = `n:${drawables.length}`;
25
+ for (let i = 0; i < drawables.length; i++) {
26
+ const d = drawables[i]!;
27
+ key += `|${d.index}:${d.invertedMask ? 1 : 0}:`;
28
+ const masks = d.maskIndices;
29
+ for (let j = 0; j < masks.length; j++) {
30
+ key += `${masks[j]!},`;
31
+ }
32
+ }
33
+ return key;
34
+ }
35
+
36
+ function atlasOptionsKey(options: MaskAtlasOptions): string {
37
+ return `${options.mode ?? "rgba"}:${options.inset ?? ""}:${options.renderTextureCount ?? ""}`;
38
+ }
39
+
40
+ type ResidentContext = {
41
+ -readonly [K in keyof LaidOutClippingContext]: LaidOutClippingContext[K];
42
+ } & { modelBounds: MaskLayoutRect };
43
+
44
+ /**
45
+ * Update `modelBounds` on resident contexts without reallocating the plan.
46
+ */
47
+ export function fitClippingContextsInPlace(
48
+ contexts: readonly ResidentContext[],
49
+ byIndex: ReadonlyMap<number, MaskBoundsMeshRef>,
50
+ margin = 0.05,
51
+ ): void {
52
+ for (let i = 0; i < contexts.length; i++) {
53
+ const ctx = contexts[i]!;
54
+ ctx.modelBounds = calcClippedDrawableBounds(
55
+ ctx.clippedIndices,
56
+ byIndex,
57
+ margin,
58
+ );
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Caches atlas layout + mask/clipped sets while drawable mask topology is
64
+ * unchanged. Call {@link resolve} each frame; call {@link clear} on destroy.
65
+ */
66
+ export class ResidentClippingPlan {
67
+ #cacheKey = "";
68
+ #contexts: ResidentContext[] = [];
69
+ #clipped: Set<number> = new Set();
70
+ #maskOnly: Set<number> = new Set();
71
+ readonly #byIndex = new Map<number, MaskBoundsMeshRef>();
72
+
73
+ /**
74
+ * Returns a partition whose `contexts` array identity is stable across
75
+ * frames with the same topology. `modelBounds` are refreshed in place.
76
+ */
77
+ resolve(
78
+ drawables: readonly ClippingDrawableRef[],
79
+ options: MaskAtlasOptions = {},
80
+ ): ClippingPartition {
81
+ const nextKey = `${clippingTopologyKey(drawables)}|${atlasOptionsKey(options)}`;
82
+ if (nextKey !== this.#cacheKey) {
83
+ const partitioned = partitionForClipping(drawables, options);
84
+ this.#contexts = partitioned.contexts.map((ctx) => ({
85
+ key: ctx.key,
86
+ maskIndices: ctx.maskIndices,
87
+ clippedIndices: ctx.clippedIndices,
88
+ invertedMask: ctx.invertedMask,
89
+ layout: ctx.layout,
90
+ channelIndex: ctx.channelIndex,
91
+ channelFlag: ctx.channelFlag,
92
+ bufferIndex: ctx.bufferIndex,
93
+ modelBounds: { ...ctx.modelBounds },
94
+ }));
95
+ this.#clipped = new Set(partitioned.clipped);
96
+ this.#maskOnly = new Set(partitioned.maskOnly);
97
+ this.#cacheKey = nextKey;
98
+ }
99
+
100
+ this.#byIndex.clear();
101
+ for (let i = 0; i < drawables.length; i++) {
102
+ const d = drawables[i]!;
103
+ if ("vertexPositions" in d) {
104
+ this.#byIndex.set(d.index, d as MaskBoundsMeshRef);
105
+ }
106
+ }
107
+ fitClippingContextsInPlace(this.#contexts, this.#byIndex);
108
+ return {
109
+ contexts: this.#contexts,
110
+ clipped: this.#clipped,
111
+ maskOnly: this.#maskOnly,
112
+ };
113
+ }
114
+
115
+ /** Convenience for full drawable meshes (GPU backends). */
116
+ resolveMeshes(
117
+ drawables: readonly DrawableMesh[],
118
+ options: MaskAtlasOptions = {},
119
+ ): ClippingPartition {
120
+ return this.resolve(drawables, options);
121
+ }
122
+
123
+ /** True when the last {@link resolve} reused the cached atlas layout. */
124
+ get hasPlan(): boolean {
125
+ return this.#cacheKey.length > 0;
126
+ }
127
+
128
+ /** Exposed for tests — context array identity while topology holds. */
129
+ get residentContexts(): readonly LaidOutClippingContext[] {
130
+ return this.#contexts;
131
+ }
132
+
133
+ clear(): void {
134
+ this.#cacheKey = "";
135
+ this.#contexts = [];
136
+ this.#clipped = new Set();
137
+ this.#maskOnly = new Set();
138
+ this.#byIndex.clear();
139
+ }
140
+ }
@@ -412,12 +412,28 @@ export function fitClippingContexts(
412
412
  }));
413
413
  }
414
414
 
415
- /** Flatten layout to `[x, y, w, h]` for GPU uniforms. */
416
- export function maskLayoutVec4(layout: MaskLayoutRect): Float32Array {
417
- return new Float32Array([layout.x, layout.y, layout.width, layout.height]);
415
+ /** Flatten layout to `[x, y, w, h]` for GPU uniforms. Reuses `into` when provided. */
416
+ export function maskLayoutVec4(
417
+ layout: MaskLayoutRect,
418
+ into?: Float32Array,
419
+ ): Float32Array {
420
+ const out = into && into.length >= 4 ? into : new Float32Array(4);
421
+ out[0] = layout.x;
422
+ out[1] = layout.y;
423
+ out[2] = layout.width;
424
+ out[3] = layout.height;
425
+ return out;
418
426
  }
419
427
 
420
- /** Flatten channel flag to `[r, g, b, a]` for GPU uniforms. */
421
- export function maskChannelVec4(flag: MaskChannelFlag): Float32Array {
422
- return new Float32Array([flag[0], flag[1], flag[2], flag[3]]);
428
+ /** Flatten channel flag to `[r, g, b, a]` for GPU uniforms. Reuses `into` when provided. */
429
+ export function maskChannelVec4(
430
+ flag: MaskChannelFlag,
431
+ into?: Float32Array,
432
+ ): Float32Array {
433
+ const out = into && into.length >= 4 ? into : new Float32Array(4);
434
+ out[0] = flag[0];
435
+ out[1] = flag[1];
436
+ out[2] = flag[2];
437
+ out[3] = flag[3];
438
+ return out;
423
439
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Pos/UV interleave with optional resident output buffer (GPU upload scratch).
3
+ */
4
+
5
+ /** Interleave `[x,y]` + `[u,v]` into `[x,y,u,v,…]`. Reuses `into` when large enough. */
6
+ export function interleavePosUv(
7
+ positions: Float32Array,
8
+ uvs: Float32Array,
9
+ into?: Float32Array,
10
+ ): Float32Array {
11
+ const n = Math.floor(positions.length / 2);
12
+ const need = n * 4;
13
+ const out = into && into.length >= need ? into : new Float32Array(need);
14
+ for (let i = 0; i < n; i++) {
15
+ const o = i * 4;
16
+ const p = i * 2;
17
+ out[o] = positions[p]!;
18
+ out[o + 1] = positions[p + 1]!;
19
+ out[o + 2] = uvs[p] ?? 0;
20
+ out[o + 3] = uvs[p + 1] ?? 0;
21
+ }
22
+ return out;
23
+ }
24
+
25
+ /** Byte length of the live interleaved prefix (vertexCount * 16). */
26
+ export function interleaveByteLength(positions: Float32Array): number {
27
+ return Math.floor(positions.length / 2) * 16;
28
+ }
@@ -69,6 +69,12 @@ export interface ModelBackend {
69
69
  /** Optional PartOpacity override (moc3 parts / pose / motion). */
70
70
  setPartOpacity?(model: InternalModel, id: string, value: number): void;
71
71
 
72
+ /**
73
+ * Resolve parameter id → index (O(1) after load).
74
+ * Used by motion blend / hosts instead of `listParameters().find`.
75
+ */
76
+ resolveParameter?(model: InternalModel, id: string): number | undefined;
77
+
72
78
  listParameters?(model: InternalModel): readonly ParameterBinding[];
73
79
  }
74
80
 
package/src/types.ts CHANGED
@@ -36,7 +36,7 @@ export interface TextureData {
36
36
  export interface ModelDrawPass {
37
37
  setTextures(textures: TextureData[]): void;
38
38
 
39
- draw(drawables: DrawableMesh[], modelMatrix: Float32Array): void;
39
+ draw(drawables: readonly DrawableMesh[], modelMatrix: Float32Array): void;
40
40
 
41
41
  destroy(): void;
42
42
  }