@doki-land/live2d 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/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
@@ -523,6 +523,8 @@ var ActorModelSlot = class {
523
523
  #backend = null;
524
524
  #lease = null;
525
525
  #loadGeneration = 0;
526
+ #paramById = /* @__PURE__ */ new Map();
527
+ #paramIndexById = /* @__PURE__ */ new Map();
526
528
  constructor(options) {
527
529
  this.#assets = options.assets;
528
530
  this.#renderer = options.renderer;
@@ -575,7 +577,7 @@ var ActorModelSlot = class {
575
577
  this.#backend.setParameter(this.#model, s.id, s.value);
576
578
  continue;
577
579
  }
578
- 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;
579
581
  this.#backend.setParameter(
580
582
  this.#model,
581
583
  s.id,
@@ -583,6 +585,35 @@ var ActorModelSlot = class {
583
585
  );
584
586
  }
585
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
+ }
586
617
  async load(source, resolver) {
587
618
  const gen = ++this.#loadGeneration;
588
619
  this.#report({
@@ -638,6 +669,7 @@ var ActorModelSlot = class {
638
669
  this.#lease = lease;
639
670
  this.#model = model;
640
671
  this.#backend = backend;
672
+ this.#rebuildParamCache();
641
673
  this.#report({
642
674
  stage: "ready",
643
675
  progress: 1,
@@ -725,6 +757,7 @@ var ActorModelSlot = class {
725
757
  }
726
758
  this.#model = null;
727
759
  this.#backend = null;
760
+ this.#clearParamCache();
728
761
  this.#releaseLease();
729
762
  this.#drawPass?.setTextures([]);
730
763
  this.#drawPass?.destroy();
@@ -734,7 +767,12 @@ var ActorModelSlot = class {
734
767
 
735
768
  // src/stage/assets/focus.ts
736
769
  function focusParameterUpdates(parameters, dragX, dragY) {
737
- const byId = new Map(parameters.map((p) => [p.id, p]));
770
+ const byId = parameters instanceof Map || isParamMap(parameters) ? parameters : new Map(
771
+ parameters.map((p) => [
772
+ p.id,
773
+ p
774
+ ])
775
+ );
738
776
  const x = clampUnit(dragX);
739
777
  const y = clampUnit(dragY);
740
778
  const out = [];
@@ -752,6 +790,9 @@ function focusParameterUpdates(parameters, dragX, dragY) {
752
790
  set("PARAM_EYE_BALL_Y", y);
753
791
  return out;
754
792
  }
793
+ function isParamMap(value) {
794
+ return typeof value === "object" && value !== null && typeof value.get === "function" && typeof value.keys === "function" && !Array.isArray(value);
795
+ }
755
796
  function clampUnit(n) {
756
797
  if (n > 1) return 1;
757
798
  if (n < -1) return -1;
@@ -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.18",
3
+ "version": "0.0.20",
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.18",
63
- "@doki-land/live2d-loader": "0.0.18",
64
- "@doki-land/live2d-renderer": "0.0.18"
62
+ "@doki-land/live2d-core": "0.0.20",
63
+ "@doki-land/live2d-loader": "0.0.20",
64
+ "@doki-land/live2d-renderer": "0.0.20"
65
65
  },
66
66
  "sideEffects": false
67
67
  }
@@ -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();
@@ -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;
@@ -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
  }