@doki-land/live2d 0.0.17 → 0.0.19

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/dist/index.d.ts CHANGED
@@ -226,6 +226,9 @@ declare class ActorModelSlot {
226
226
  get model(): InternalModel | null;
227
227
  get drawPass(): ModelDrawPass | null;
228
228
  ensureDrawPass(): ModelDrawPass;
229
+ /** Load-time stable id → binding map (binding.value updated on setParameter). */
230
+ parameterMap(): ReadonlyMap<string, ParameterBinding>;
231
+ resolveParameter(id: string): number | undefined;
229
232
  load(source: ModelSource, resolver?: AssetResolver): Promise<InternalModel>;
230
233
  loadAsset(asset: ModelAsset): Promise<InternalModel>;
231
234
  setParameter(id: string, value: number): void;
@@ -274,6 +277,8 @@ declare class Live2dActorImpl implements Live2dActor {
274
277
  loadAsset(asset: ModelAsset): Promise<InternalModel>;
275
278
  setParameter(id: string, value: number): void;
276
279
  listParameters(): readonly _doki_land_live2d_renderer.ParameterBinding[];
280
+ parameterMap(): ReadonlyMap<string, _doki_land_live2d_renderer.ParameterBinding>;
281
+ resolveParameter(id: string): number | undefined;
277
282
  listMotionGroups(): Record<string, readonly _doki_land_live2d_core.MotionDefinition[]>;
278
283
  playMotion(group: string, index?: number, options?: PlayMotionActorOptions): Promise<boolean>;
279
284
  stopMotion(opts?: {
@@ -343,6 +348,9 @@ interface Live2DRuntime extends Live2DSession {
343
348
  readonly actor: Live2dActorImpl;
344
349
  setParameter(id: string, value: number): void;
345
350
  listParameters(): readonly ParameterBinding[];
351
+ /** Stable load-time parameter map for focus / hosts (no per-frame rebuild). */
352
+ parameterMap(): ReadonlyMap<string, ParameterBinding>;
353
+ resolveParameter(id: string): number | undefined;
346
354
  hitTest(x: number, y: number): string | null;
347
355
  listMotionGroups(): Record<string, readonly _doki_land_live2d_core.MotionDefinition[]>;
348
356
  playMotion(group: string, index?: number, options?: PlayMotionOptions): Promise<boolean>;
@@ -372,8 +380,10 @@ declare function createLive2D(options?: CreateLive2DOptions): Live2DRuntime;
372
380
  * ANGLE_X/Y use the full declared range, ANGLE_Z combines both axes, and body
373
381
  * and eye parameters receive the corresponding normalized axis. Parameters not
374
382
  * declared by a model are skipped.
383
+ *
384
+ * Prefer a stable `Map` from load-time bindings to avoid per-frame Map rebuild.
375
385
  */
376
- declare function focusParameterUpdates(parameters: readonly ParameterBinding[], dragX: number, dragY: number): Array<{
386
+ declare function focusParameterUpdates(parameters: readonly ParameterBinding[] | ReadonlyMap<string, ParameterBinding>, dragX: number, dragY: number): Array<{
377
387
  id: string;
378
388
  value: number;
379
389
  }>;
package/dist/index.js CHANGED
@@ -27,36 +27,6 @@ import {
27
27
  createRenderer as createRenderer2
28
28
  } from "@doki-land/live2d-renderer";
29
29
 
30
- // src/stage/assets/focus.ts
31
- function focusParameterUpdates(parameters, dragX, dragY) {
32
- const byId = new Map(parameters.map((p) => [p.id, p]));
33
- const x = clampUnit(dragX);
34
- const y = clampUnit(dragY);
35
- const out = [];
36
- const set = (id, normalized) => {
37
- const binding = byId.get(id);
38
- if (!binding) return;
39
- out.push({ id, value: valueFromNormalized(binding, normalized) });
40
- };
41
- set("PARAM_ANGLE_X", x);
42
- set("PARAM_ANGLE_Y", y);
43
- set("PARAM_ANGLE_Z", clampUnit(x * y * -1));
44
- set("PARAM_BODY_ANGLE_X", x);
45
- set("PARAM_BODY_ANGLE_Y", y);
46
- set("PARAM_EYE_BALL_X", x);
47
- set("PARAM_EYE_BALL_Y", y);
48
- return out;
49
- }
50
- function clampUnit(n) {
51
- if (n > 1) return 1;
52
- if (n < -1) return -1;
53
- return n;
54
- }
55
- function valueFromNormalized(binding, normalized) {
56
- const n = clampUnit(normalized);
57
- return n >= 0 ? binding.defaultValue + (binding.max - binding.defaultValue) * n : binding.defaultValue + (binding.defaultValue - binding.min) * n;
58
- }
59
-
60
30
  // src/motion/evaluate-curve.ts
61
31
  function evaluateMotion3(clip, timeSeconds) {
62
32
  const t = clamp(timeSeconds, 0, clip.duration);
@@ -553,6 +523,8 @@ var ActorModelSlot = class {
553
523
  #backend = null;
554
524
  #lease = null;
555
525
  #loadGeneration = 0;
526
+ #paramById = /* @__PURE__ */ new Map();
527
+ #paramIndexById = /* @__PURE__ */ new Map();
556
528
  constructor(options) {
557
529
  this.#assets = options.assets;
558
530
  this.#renderer = options.renderer;
@@ -605,7 +577,7 @@ var ActorModelSlot = class {
605
577
  this.#backend.setParameter(this.#model, s.id, s.value);
606
578
  continue;
607
579
  }
608
- const cur = this.#backend.listParameters?.(this.#model).find((p) => p.id === s.id)?.value ?? s.value;
580
+ const cur = this.#paramById.get(s.id)?.value ?? s.value;
609
581
  this.#backend.setParameter(
610
582
  this.#model,
611
583
  s.id,
@@ -613,6 +585,35 @@ var ActorModelSlot = class {
613
585
  );
614
586
  }
615
587
  }
588
+ #rebuildParamCache() {
589
+ this.#paramById.clear();
590
+ this.#paramIndexById.clear();
591
+ if (!this.#model || !this.#backend?.listParameters) return;
592
+ const list = this.#backend.listParameters(this.#model);
593
+ for (let i = 0; i < list.length; i++) {
594
+ const p = list[i];
595
+ this.#paramById.set(p.id, p);
596
+ const resolved = this.#backend.resolveParameter?.(
597
+ this.#model,
598
+ p.id
599
+ );
600
+ this.#paramIndexById.set(p.id, resolved ?? i);
601
+ }
602
+ }
603
+ #clearParamCache() {
604
+ this.#paramById.clear();
605
+ this.#paramIndexById.clear();
606
+ }
607
+ /** Load-time stable id → binding map (binding.value updated on setParameter). */
608
+ parameterMap() {
609
+ return this.#paramById;
610
+ }
611
+ resolveParameter(id) {
612
+ if (this.#model && this.#backend?.resolveParameter) {
613
+ return this.#backend.resolveParameter(this.#model, id);
614
+ }
615
+ return this.#paramIndexById.get(id);
616
+ }
616
617
  async load(source, resolver) {
617
618
  const gen = ++this.#loadGeneration;
618
619
  this.#report({
@@ -668,6 +669,7 @@ var ActorModelSlot = class {
668
669
  this.#lease = lease;
669
670
  this.#model = model;
670
671
  this.#backend = backend;
672
+ this.#rebuildParamCache();
671
673
  this.#report({
672
674
  stage: "ready",
673
675
  progress: 1,
@@ -755,6 +757,7 @@ var ActorModelSlot = class {
755
757
  }
756
758
  this.#model = null;
757
759
  this.#backend = null;
760
+ this.#clearParamCache();
758
761
  this.#releaseLease();
759
762
  this.#drawPass?.setTextures([]);
760
763
  this.#drawPass?.destroy();
@@ -762,6 +765,44 @@ var ActorModelSlot = class {
762
765
  }
763
766
  };
764
767
 
768
+ // src/stage/assets/focus.ts
769
+ function focusParameterUpdates(parameters, dragX, dragY) {
770
+ const byId = parameters instanceof Map || isParamMap(parameters) ? parameters : new Map(
771
+ parameters.map((p) => [
772
+ p.id,
773
+ p
774
+ ])
775
+ );
776
+ const x = clampUnit(dragX);
777
+ const y = clampUnit(dragY);
778
+ const out = [];
779
+ const set = (id, normalized) => {
780
+ const binding = byId.get(id);
781
+ if (!binding) return;
782
+ out.push({ id, value: valueFromNormalized(binding, normalized) });
783
+ };
784
+ set("PARAM_ANGLE_X", x);
785
+ set("PARAM_ANGLE_Y", y);
786
+ set("PARAM_ANGLE_Z", clampUnit(x * y * -1));
787
+ set("PARAM_BODY_ANGLE_X", x);
788
+ set("PARAM_BODY_ANGLE_Y", y);
789
+ set("PARAM_EYE_BALL_X", x);
790
+ set("PARAM_EYE_BALL_Y", y);
791
+ return out;
792
+ }
793
+ function isParamMap(value) {
794
+ return typeof value === "object" && value !== null && typeof value.get === "function" && typeof value.keys === "function" && !Array.isArray(value);
795
+ }
796
+ function clampUnit(n) {
797
+ if (n > 1) return 1;
798
+ if (n < -1) return -1;
799
+ return n;
800
+ }
801
+ function valueFromNormalized(binding, normalized) {
802
+ const n = clampUnit(normalized);
803
+ return n >= 0 ? binding.defaultValue + (binding.max - binding.defaultValue) * n : binding.defaultValue + (binding.defaultValue - binding.min) * n;
804
+ }
805
+
765
806
  // src/stage/transform.ts
766
807
  import { DEFAULT_ACTOR_TRANSFORM } from "@doki-land/live2d-core";
767
808
  function resolveActorTransform(patch) {
@@ -843,26 +884,74 @@ function compareActorsForDraw(a, b, definedLayers) {
843
884
  function compareActorsForHit(a, b, definedLayers) {
844
885
  return compareActorsForDraw(b, a, definedLayers);
845
886
  }
846
- function transformDrawablesForStage(drawables, transform, opacity) {
847
- const alpha = Math.min(1, Math.max(0, opacity));
848
- return drawables.map((d) => {
849
- if (!d.visible || alpha <= 0) return { ...d, visible: false };
850
- const pos = new Float32Array(d.vertexPositions.length);
851
- for (let i = 0; i < pos.length; i += 2) {
852
- const { stageX, stageY } = modelNdcToStage(
853
- d.vertexPositions[i],
854
- d.vertexPositions[i + 1],
855
- transform
856
- );
857
- pos[i] = stageX * 2 - 1;
858
- pos[i + 1] = 1 - stageY * 2;
887
+ var StageDrawableScratch = class {
888
+ #meshes = [];
889
+ #view = [];
890
+ transform(drawables, transform, opacity) {
891
+ const alpha = Math.min(1, Math.max(0, opacity));
892
+ const n = drawables.length;
893
+ while (this.#meshes.length < n) {
894
+ this.#meshes.push(createScratchMesh());
895
+ }
896
+ if (this.#view.length !== n) {
897
+ this.#view = this.#meshes.slice(0, n);
898
+ }
899
+ for (let i = 0; i < n; i++) {
900
+ const src = drawables[i];
901
+ const dst = this.#meshes[i];
902
+ copyMeshMeta(src, dst);
903
+ if (!src.visible || alpha <= 0) {
904
+ dst.visible = false;
905
+ dst.opacity = 0;
906
+ continue;
907
+ }
908
+ const len = src.vertexPositions.length;
909
+ let pos = dst.vertexPositions;
910
+ if (pos.length !== len) {
911
+ pos = new Float32Array(len);
912
+ dst.vertexPositions = pos;
913
+ }
914
+ for (let j = 0; j < len; j += 2) {
915
+ const { stageX, stageY } = modelNdcToStage(
916
+ src.vertexPositions[j],
917
+ src.vertexPositions[j + 1],
918
+ transform
919
+ );
920
+ pos[j] = stageX * 2 - 1;
921
+ pos[j + 1] = 1 - stageY * 2;
922
+ }
923
+ dst.opacity = src.opacity * alpha;
924
+ dst.visible = true;
859
925
  }
860
- return {
861
- ...d,
862
- vertexPositions: pos,
863
- opacity: d.opacity * alpha
864
- };
865
- });
926
+ return this.#view;
927
+ }
928
+ };
929
+ function createScratchMesh() {
930
+ return {
931
+ index: 0,
932
+ textureIndex: 0,
933
+ vertexPositions: new Float32Array(0),
934
+ uvs: new Float32Array(0),
935
+ indices: new Uint16Array(0),
936
+ opacity: 1,
937
+ blendMode: 0,
938
+ invertedMask: false,
939
+ renderOrder: 0,
940
+ dynamicFlag: true,
941
+ maskIndices: [],
942
+ visible: true
943
+ };
944
+ }
945
+ function copyMeshMeta(src, dst) {
946
+ dst.index = src.index;
947
+ dst.textureIndex = src.textureIndex;
948
+ dst.uvs = src.uvs;
949
+ dst.indices = src.indices;
950
+ dst.blendMode = src.blendMode;
951
+ dst.invertedMask = src.invertedMask;
952
+ dst.renderOrder = src.renderOrder;
953
+ dst.dynamicFlag = src.dynamicFlag;
954
+ dst.maskIndices = src.maskIndices;
866
955
  }
867
956
 
868
957
  // src/stage/actor.ts
@@ -945,6 +1034,12 @@ var Live2dActorImpl = class {
945
1034
  listParameters() {
946
1035
  return this.#slot.listParameters();
947
1036
  }
1037
+ parameterMap() {
1038
+ return this.#slot.parameterMap();
1039
+ }
1040
+ resolveParameter(id) {
1041
+ return this.#slot.resolveParameter(id);
1042
+ }
948
1043
  listMotionGroups() {
949
1044
  return this.#slot.listMotionGroups();
950
1045
  }
@@ -968,7 +1063,7 @@ var Live2dActorImpl = class {
968
1063
  this.#transform
969
1064
  );
970
1065
  for (const u of focusParameterUpdates(
971
- this.#slot.listParameters(),
1066
+ this.#slot.parameterMap(),
972
1067
  dragX,
973
1068
  dragY
974
1069
  )) {
@@ -1099,6 +1194,12 @@ function createSingleActorFacade(stage, actor, backends) {
1099
1194
  listParameters() {
1100
1195
  return actor.listParameters();
1101
1196
  },
1197
+ parameterMap() {
1198
+ return actor.parameterMap();
1199
+ },
1200
+ resolveParameter(id) {
1201
+ return actor.resolveParameter(id);
1202
+ },
1102
1203
  listMotionGroups() {
1103
1204
  return actor.listMotionGroups();
1104
1205
  },
@@ -1491,6 +1592,14 @@ var ModelAssetRegistry = class {
1491
1592
  };
1492
1593
 
1493
1594
  // src/stage/stage.ts
1595
+ var IDENTITY_MAT4 = /* @__PURE__ */ (() => {
1596
+ const m = new Float32Array(16);
1597
+ m[0] = 1;
1598
+ m[5] = 1;
1599
+ m[10] = 1;
1600
+ m[15] = 1;
1601
+ return m;
1602
+ })();
1494
1603
  var Live2dStageImpl = class {
1495
1604
  #backends;
1496
1605
  #renderer;
@@ -1509,6 +1618,8 @@ var Live2dStageImpl = class {
1509
1618
  ["pointermove", /* @__PURE__ */ new Set()],
1510
1619
  ["pointerup", /* @__PURE__ */ new Set()]
1511
1620
  ]);
1621
+ #drawScratch = new StageDrawableScratch();
1622
+ #sortedActors = [];
1512
1623
  #canvas = null;
1513
1624
  #initPromise = null;
1514
1625
  #rafId = null;
@@ -1613,21 +1724,24 @@ var Live2dStageImpl = class {
1613
1724
  }
1614
1725
  render() {
1615
1726
  if (this.#destroyed || !this.#canvas) return;
1616
- const sorted = [...this.#actors.values()].sort(
1617
- (a, b) => compareActorsForDraw(a, b, this.#definedLayers)
1618
- );
1727
+ const sorted = this.#sortedActors;
1728
+ sorted.length = 0;
1729
+ for (const actor of this.#actors.values()) {
1730
+ sorted.push(actor);
1731
+ }
1732
+ sorted.sort((a, b) => compareActorsForDraw(a, b, this.#definedLayers));
1619
1733
  this.#renderer.beginFrame();
1620
1734
  for (const actor of sorted) {
1621
1735
  if (!actor.visible || actor.opacity <= 0) continue;
1622
1736
  const drawables = actor.lastDrawables;
1623
1737
  const pass = actor.slot.drawPass;
1624
1738
  if (!drawables || !pass) continue;
1625
- const placed = transformDrawablesForStage(
1739
+ const placed = this.#drawScratch.transform(
1626
1740
  drawables,
1627
1741
  actor.getTransform(),
1628
1742
  actor.opacity
1629
1743
  );
1630
- pass.draw(placed, new Float32Array(16));
1744
+ pass.draw(placed, IDENTITY_MAT4);
1631
1745
  }
1632
1746
  this.#renderer.endFrame();
1633
1747
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doki-land/live2d",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
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.17",
63
- "@doki-land/live2d-loader": "0.0.17",
64
- "@doki-land/live2d-renderer": "0.0.17"
62
+ "@doki-land/live2d-core": "0.0.19",
63
+ "@doki-land/live2d-loader": "0.0.19",
64
+ "@doki-land/live2d-renderer": "0.0.19"
65
65
  },
66
66
  "sideEffects": false
67
67
  }
package/src/index.ts CHANGED
@@ -73,7 +73,6 @@ export {
73
73
  MotionPriority,
74
74
  type PlayMotionOptions,
75
75
  } from "./facade/create-live2d.js";
76
- export { focusParameterUpdates } from "./stage/assets/focus.js";
77
76
  export {
78
77
  blendMotionLayers,
79
78
  evaluateCurve,
@@ -83,6 +82,7 @@ export {
83
82
  MotionPlayer,
84
83
  parseMotion3,
85
84
  } from "./motion/index.js";
85
+ export { focusParameterUpdates } from "./stage/assets/focus.js";
86
86
  export {
87
87
  type CreateLive2dStageFullOptions,
88
88
  createLive2dStage,
@@ -52,6 +52,8 @@ export class ActorModelSlot {
52
52
  #backend: ModelBackend | null = null;
53
53
  #lease: ModelAssetLease | null = null;
54
54
  #loadGeneration = 0;
55
+ readonly #paramById = new Map<string, ParameterBinding>();
56
+ readonly #paramIndexById = new Map<string, number>();
55
57
 
56
58
  constructor(options: ActorModelSlotOptions) {
57
59
  this.#assets = options.assets;
@@ -111,10 +113,7 @@ export class ActorModelSlot {
111
113
  this.#backend.setParameter(this.#model, s.id, s.value);
112
114
  continue;
113
115
  }
114
- const cur =
115
- this.#backend
116
- .listParameters?.(this.#model)
117
- .find((p) => p.id === s.id)?.value ?? s.value;
116
+ const cur = this.#paramById.get(s.id)?.value ?? s.value;
118
117
  this.#backend.setParameter(
119
118
  this.#model,
120
119
  s.id,
@@ -123,6 +122,39 @@ export class ActorModelSlot {
123
122
  }
124
123
  }
125
124
 
125
+ #rebuildParamCache(): void {
126
+ this.#paramById.clear();
127
+ this.#paramIndexById.clear();
128
+ if (!this.#model || !this.#backend?.listParameters) return;
129
+ const list = this.#backend.listParameters(this.#model);
130
+ for (let i = 0; i < list.length; i++) {
131
+ const p = list[i]!;
132
+ this.#paramById.set(p.id, p);
133
+ const resolved = this.#backend.resolveParameter?.(
134
+ this.#model,
135
+ p.id,
136
+ );
137
+ this.#paramIndexById.set(p.id, resolved ?? i);
138
+ }
139
+ }
140
+
141
+ #clearParamCache(): void {
142
+ this.#paramById.clear();
143
+ this.#paramIndexById.clear();
144
+ }
145
+
146
+ /** Load-time stable id → binding map (binding.value updated on setParameter). */
147
+ parameterMap(): ReadonlyMap<string, ParameterBinding> {
148
+ return this.#paramById;
149
+ }
150
+
151
+ resolveParameter(id: string): number | undefined {
152
+ if (this.#model && this.#backend?.resolveParameter) {
153
+ return this.#backend.resolveParameter(this.#model, id);
154
+ }
155
+ return this.#paramIndexById.get(id);
156
+ }
157
+
126
158
  async load(
127
159
  source: ModelSource,
128
160
  resolver?: AssetResolver,
@@ -193,6 +225,7 @@ export class ActorModelSlot {
193
225
  this.#lease = lease;
194
226
  this.#model = model;
195
227
  this.#backend = backend;
228
+ this.#rebuildParamCache();
196
229
  this.#report({
197
230
  stage: "ready",
198
231
  progress: 1,
@@ -317,6 +350,7 @@ export class ActorModelSlot {
317
350
  }
318
351
  this.#model = null;
319
352
  this.#backend = null;
353
+ this.#clearParamCache();
320
354
  this.#releaseLease();
321
355
  this.#drawPass?.setTextures([]);
322
356
  this.#drawPass?.destroy();
@@ -8,9 +8,9 @@ import type {
8
8
  PlayMotionActorOptions,
9
9
  } from "@doki-land/live2d-core";
10
10
  import type { DrawableMesh, Renderer } from "@doki-land/live2d-renderer";
11
- import { focusParameterUpdates } from "./assets/focus.js";
12
11
  import type { PlayMotionOptions } from "../motion/index.js";
13
12
  import { ActorModelSlot } from "./actor-model-slot.js";
13
+ import { focusParameterUpdates } from "./assets/focus.js";
14
14
  import type { ModelAssetRegistry } from "./model-asset-registry.js";
15
15
  import {
16
16
  resolveActorTransform,
@@ -128,6 +128,14 @@ export class Live2dActorImpl implements Live2dActor {
128
128
  return this.#slot.listParameters();
129
129
  }
130
130
 
131
+ parameterMap() {
132
+ return this.#slot.parameterMap();
133
+ }
134
+
135
+ resolveParameter(id: string) {
136
+ return this.#slot.resolveParameter(id);
137
+ }
138
+
131
139
  listMotionGroups() {
132
140
  return this.#slot.listMotionGroups();
133
141
  }
@@ -159,7 +167,7 @@ export class Live2dActorImpl implements Live2dActor {
159
167
  this.#transform,
160
168
  );
161
169
  for (const u of focusParameterUpdates(
162
- this.#slot.listParameters(),
170
+ this.#slot.parameterMap(),
163
171
  dragX,
164
172
  dragY,
165
173
  )) {
@@ -6,13 +6,25 @@ import type { ParameterBinding } from "@doki-land/live2d-renderer";
6
6
  * ANGLE_X/Y use the full declared range, ANGLE_Z combines both axes, and body
7
7
  * and eye parameters receive the corresponding normalized axis. Parameters not
8
8
  * declared by a model are skipped.
9
+ *
10
+ * Prefer a stable `Map` from load-time bindings to avoid per-frame Map rebuild.
9
11
  */
10
12
  export function focusParameterUpdates(
11
- parameters: readonly ParameterBinding[],
13
+ parameters:
14
+ | readonly ParameterBinding[]
15
+ | ReadonlyMap<string, ParameterBinding>,
12
16
  dragX: number,
13
17
  dragY: number,
14
18
  ): Array<{ id: string; value: number }> {
15
- const byId = new Map(parameters.map((p) => [p.id, p]));
19
+ const byId =
20
+ parameters instanceof Map || isParamMap(parameters)
21
+ ? parameters
22
+ : new Map(
23
+ (parameters as readonly ParameterBinding[]).map((p) => [
24
+ p.id,
25
+ p,
26
+ ]),
27
+ );
16
28
  const x = clampUnit(dragX);
17
29
  const y = clampUnit(dragY);
18
30
  const out: Array<{ id: string; value: number }> = [];
@@ -35,6 +47,18 @@ export function focusParameterUpdates(
35
47
  return out;
36
48
  }
37
49
 
50
+ function isParamMap(
51
+ value: unknown,
52
+ ): value is ReadonlyMap<string, ParameterBinding> {
53
+ return (
54
+ typeof value === "object" &&
55
+ value !== null &&
56
+ typeof (value as Map<string, unknown>).get === "function" &&
57
+ typeof (value as Map<string, unknown>).keys === "function" &&
58
+ !Array.isArray(value)
59
+ );
60
+ }
61
+
38
62
  function clampUnit(n: number): number {
39
63
  if (n > 1) return 1;
40
64
  if (n < -1) return -1;
@@ -23,8 +23,8 @@ import {
23
23
  compileSharedModelCompile,
24
24
  selectModelBackend,
25
25
  } from "@doki-land/live2d-renderer";
26
- import { loadTextureData, releaseTextureData } from "./assets/load-textures.js";
27
26
  import type { Motion3Clip } from "../motion/index.js";
27
+ import { loadTextureData, releaseTextureData } from "./assets/load-textures.js";
28
28
  import { resolveModelAssetKey } from "./model-asset-key.js";
29
29
 
30
30
  function lerp(a: number, b: number, t: number): number {
@@ -33,6 +33,9 @@ export interface Live2DRuntime extends Live2DSession {
33
33
 
34
34
  setParameter(id: string, value: number): void;
35
35
  listParameters(): readonly ParameterBinding[];
36
+ /** Stable load-time parameter map for focus / hosts (no per-frame rebuild). */
37
+ parameterMap(): ReadonlyMap<string, ParameterBinding>;
38
+ resolveParameter(id: string): number | undefined;
36
39
  hitTest(x: number, y: number): string | null;
37
40
  listMotionGroups(): Record<
38
41
  string,
@@ -141,6 +144,12 @@ export function createSingleActorFacade(
141
144
  listParameters() {
142
145
  return actor.listParameters();
143
146
  },
147
+ parameterMap() {
148
+ return actor.parameterMap();
149
+ },
150
+ resolveParameter(id) {
151
+ return actor.resolveParameter(id);
152
+ },
144
153
  listMotionGroups() {
145
154
  return actor.listMotionGroups();
146
155
  },
@@ -23,9 +23,19 @@ import {
23
23
  clientToStage,
24
24
  compareActorsForDraw,
25
25
  compareActorsForHit,
26
- transformDrawablesForStage,
26
+ StageDrawableScratch,
27
27
  } from "./transform.js";
28
28
 
29
+ /** Resident identity model matrix — backends currently ignore it; never alloc per frame. */
30
+ export const IDENTITY_MAT4 = /* @__PURE__ */ (() => {
31
+ const m = new Float32Array(16);
32
+ m[0] = 1;
33
+ m[5] = 1;
34
+ m[10] = 1;
35
+ m[15] = 1;
36
+ return m;
37
+ })();
38
+
29
39
  export interface CreateLive2dStageFullOptions extends CreateLive2dStageOptions {
30
40
  backends?: ModelBackend[];
31
41
  renderer?: Renderer;
@@ -55,6 +65,8 @@ export class Live2dStageImpl implements Live2dStage {
55
65
  ["pointermove", new Set()],
56
66
  ["pointerup", new Set()],
57
67
  ]);
68
+ readonly #drawScratch = new StageDrawableScratch();
69
+ readonly #sortedActors: Live2dActorImpl[] = [];
58
70
 
59
71
  #canvas: HTMLCanvasElement | null = null;
60
72
  #initPromise: Promise<void> | null = null;
@@ -178,9 +190,12 @@ export class Live2dStageImpl implements Live2dStage {
178
190
 
179
191
  render(): void {
180
192
  if (this.#destroyed || !this.#canvas) return;
181
- const sorted = [...this.#actors.values()].sort((a, b) =>
182
- compareActorsForDraw(a, b, this.#definedLayers),
183
- );
193
+ const sorted = this.#sortedActors;
194
+ sorted.length = 0;
195
+ for (const actor of this.#actors.values()) {
196
+ sorted.push(actor);
197
+ }
198
+ sorted.sort((a, b) => compareActorsForDraw(a, b, this.#definedLayers));
184
199
 
185
200
  this.#renderer.beginFrame();
186
201
  for (const actor of sorted) {
@@ -188,12 +203,12 @@ export class Live2dStageImpl implements Live2dStage {
188
203
  const drawables = actor.lastDrawables;
189
204
  const pass = actor.slot.drawPass;
190
205
  if (!drawables || !pass) continue;
191
- const placed = transformDrawablesForStage(
206
+ const placed = this.#drawScratch.transform(
192
207
  drawables,
193
208
  actor.getTransform(),
194
209
  actor.opacity,
195
210
  );
196
- pass.draw(placed, new Float32Array(16));
211
+ pass.draw(placed, IDENTITY_MAT4);
197
212
  }
198
213
  this.#renderer.endFrame();
199
214
  }
@@ -133,29 +133,96 @@ export function compareActorsForHit<
133
133
  return compareActorsForDraw(b, a, definedLayers);
134
134
  }
135
135
 
136
+ /**
137
+ * Resident scratch for {@link transformDrawablesForStage}: stable mesh objects
138
+ * and vertex buffers (no per-frame `map` / `{...d}` / `new Float32Array`).
139
+ * Safe to reuse across actors when each transformed list is drawn before the
140
+ * next `transform` call.
141
+ */
142
+ export class StageDrawableScratch {
143
+ readonly #meshes: DrawableMesh[] = [];
144
+ #view: DrawableMesh[] = [];
145
+
146
+ transform(
147
+ drawables: readonly DrawableMesh[],
148
+ transform: ActorTransform,
149
+ opacity: number,
150
+ ): readonly DrawableMesh[] {
151
+ const alpha = Math.min(1, Math.max(0, opacity));
152
+ const n = drawables.length;
153
+ while (this.#meshes.length < n) {
154
+ this.#meshes.push(createScratchMesh());
155
+ }
156
+ if (this.#view.length !== n) {
157
+ this.#view = this.#meshes.slice(0, n);
158
+ }
159
+ for (let i = 0; i < n; i++) {
160
+ const src = drawables[i]!;
161
+ const dst = this.#meshes[i]!;
162
+ copyMeshMeta(src, dst);
163
+ if (!src.visible || alpha <= 0) {
164
+ dst.visible = false;
165
+ dst.opacity = 0;
166
+ continue;
167
+ }
168
+ const len = src.vertexPositions.length;
169
+ let pos = dst.vertexPositions;
170
+ if (pos.length !== len) {
171
+ pos = new Float32Array(len);
172
+ dst.vertexPositions = pos;
173
+ }
174
+ for (let j = 0; j < len; j += 2) {
175
+ const { stageX, stageY } = modelNdcToStage(
176
+ src.vertexPositions[j]!,
177
+ src.vertexPositions[j + 1]!,
178
+ transform,
179
+ );
180
+ pos[j] = stageX * 2 - 1;
181
+ pos[j + 1] = 1 - stageY * 2;
182
+ }
183
+ dst.opacity = src.opacity * alpha;
184
+ dst.visible = true;
185
+ }
186
+ return this.#view;
187
+ }
188
+ }
189
+
190
+ function createScratchMesh(): DrawableMesh {
191
+ return {
192
+ index: 0,
193
+ textureIndex: 0,
194
+ vertexPositions: new Float32Array(0),
195
+ uvs: new Float32Array(0),
196
+ indices: new Uint16Array(0),
197
+ opacity: 1,
198
+ blendMode: 0,
199
+ invertedMask: false,
200
+ renderOrder: 0,
201
+ dynamicFlag: true,
202
+ maskIndices: [],
203
+ visible: true,
204
+ };
205
+ }
206
+
207
+ function copyMeshMeta(src: DrawableMesh, dst: DrawableMesh): void {
208
+ dst.index = src.index;
209
+ dst.textureIndex = src.textureIndex;
210
+ dst.uvs = src.uvs;
211
+ dst.indices = src.indices;
212
+ dst.blendMode = src.blendMode;
213
+ dst.invertedMask = src.invertedMask;
214
+ dst.renderOrder = src.renderOrder;
215
+ dst.dynamicFlag = src.dynamicFlag;
216
+ dst.maskIndices = src.maskIndices;
217
+ }
218
+
136
219
  /** Bake actor stage placement into drawable vertices (renderer stays single-pass). */
137
220
  export function transformDrawablesForStage(
138
221
  drawables: readonly DrawableMesh[],
139
222
  transform: ActorTransform,
140
223
  opacity: number,
141
- ): DrawableMesh[] {
142
- const alpha = Math.min(1, Math.max(0, opacity));
143
- return drawables.map((d) => {
144
- if (!d.visible || alpha <= 0) return { ...d, visible: false };
145
- const pos = new Float32Array(d.vertexPositions.length);
146
- for (let i = 0; i < pos.length; i += 2) {
147
- const { stageX, stageY } = modelNdcToStage(
148
- d.vertexPositions[i]!,
149
- d.vertexPositions[i + 1]!,
150
- transform,
151
- );
152
- pos[i] = stageX * 2 - 1;
153
- pos[i + 1] = 1 - stageY * 2;
154
- }
155
- return {
156
- ...d,
157
- vertexPositions: pos,
158
- opacity: d.opacity * alpha,
159
- };
160
- });
224
+ scratch?: StageDrawableScratch,
225
+ ): readonly DrawableMesh[] {
226
+ const pool = scratch ?? new StageDrawableScratch();
227
+ return pool.transform(drawables, transform, opacity);
161
228
  }