@vgai/engine 0.5.18 → 0.5.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.
@@ -1 +1 @@
1
- {"version":3,"file":"authoring.d.ts","sourceRoot":"","sources":["../../src/pixi/authoring.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"authoring.d.ts","sourceRoot":"","sources":["../../src/pixi/authoring.ts"],"names":[],"mappings":"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"}
@@ -120,62 +120,141 @@ function isPixiDisplay(value) {
120
120
  * never reached `_cannon` inside the budget.
121
121
  */
122
122
  function viewOwnerLabel(view) {
123
- const seen = new WeakSet();
123
+ const search = { seen: new WeakSet(), budget: OWNER_SEARCH_BUDGET };
124
124
  let host = view.parent;
125
125
  for (let hops = 0; hops < 8 && host && typeof host === 'object'; hops++) {
126
- const found = labelFromHolder(host, view, seen) ?? findHeldOwner(host, view, 2, seen);
126
+ if (search.budget <= 0)
127
+ return undefined;
128
+ const found = labelFromHolder(host, view, search) ?? findHeldOwner(host, view, 2, search);
127
129
  if (found)
128
130
  return found;
129
131
  host = host.parent;
130
132
  }
131
133
  return undefined;
132
134
  }
135
+ /**
136
+ * THE CEILING ON ONE NODE'S OWNER HUNT — what keeps a hierarchy read LINEAR in
137
+ * the size of the tree instead of quadratic in it.
138
+ *
139
+ * The hunt below is a search of the GAME's object graph, and a game's object
140
+ * graph is small: `this._cannon`, `allSystems.get('hud')`, `_decor[3]` are all
141
+ * found within a few dozen examinations. What is not small is anything the
142
+ * search can leak into — Pixi's own live render graph is the measured one, and
143
+ * naming a key blacklist is a fix for the leak you already found, not for the
144
+ * class. So every examination is charged against this budget and the search
145
+ * simply gives up when it runs out: the node then falls through to its texture
146
+ * alias / constructor / kind name, which is the same honest answer it gets
147
+ * today whenever the hunt finds nothing.
148
+ *
149
+ * MEASURED (2026-08-20, `packages/editor/scripts/scale-harness`): entering play
150
+ * on a 20 000-node `@pixi/react` world blocked the main thread for 199.5
151
+ * SECONDS in one synchronous run, 69% of all profiler samples inside this
152
+ * search, because `parentRenderGroup` reaches an array of every node in the
153
+ * tree and the search walked it once per unlabeled node. `vgai play`,
154
+ * `screenshot` and `stop` all timed out against a tab that was heartbeating
155
+ * normally the whole time.
156
+ */
157
+ const OWNER_SEARCH_BUDGET = 2_000;
158
+ /** Charge one examination. `false` means the hunt is over. */
159
+ function spend(search) {
160
+ if (search.budget <= 0)
161
+ return false;
162
+ search.budget -= 1;
163
+ return true;
164
+ }
133
165
  function isGameFieldKey(key) {
134
166
  // Skip display-list indices (`0`, `1`) and Pixi internals (`children`).
135
- return /^_?[A-Za-z][A-Za-zA-Z0-9]*$/.test(key) && !PIXI_INTERNAL_KEY.has(key);
167
+ // The membership test is on the UNDERSCORED-STRIPPED key because that is how
168
+ // Pixi v8 spells most of them (`_position`, `_bounds`, `_texture`), and a
169
+ // set listing the bare names was silently matching none of those.
170
+ if (!/^_?[A-Za-z][A-Za-zA-Z0-9]*$/.test(key))
171
+ return false;
172
+ return !PIXI_INTERNAL_KEY.has(key.replace(/^_+/, ''));
136
173
  }
174
+ /**
175
+ * Field names that belong to Pixi (or to its EventEmitter base), never to the
176
+ * game. Read off a live `pixi.js` v8 `Container`/`Sprite`'s own keys, so this
177
+ * is a transcription rather than a guess — but it is the OPTIMIZATION, not the
178
+ * correctness boundary: {@link OWNER_SEARCH_BUDGET} is what bounds the search
179
+ * on a Pixi version whose internals this list has never heard of. Spending the
180
+ * budget on plausible game fields instead of on the render graph is what keeps
181
+ * the heuristic's hit rate while it is bounded.
182
+ *
183
+ * `view` is deliberately absent: `.view` is the very field the hunt is looking
184
+ * for.
185
+ */
137
186
  const PIXI_INTERNAL_KEY = new Set([
138
187
  'parent',
139
188
  'children',
140
189
  'transform',
141
190
  'position',
191
+ 'origin',
142
192
  'scale',
143
193
  'pivot',
144
194
  'skew',
195
+ 'anchor',
145
196
  'worldTransform',
146
197
  'localTransform',
198
+ 'groupTransform',
199
+ 'relativeGroupTransform',
200
+ // The render graph — the measured leak. `parentRenderGroup` is on every
201
+ // container once a world has rendered and reaches every node in the tree.
202
+ 'renderGroup',
203
+ 'parentRenderGroup',
204
+ 'parentRenderGroupIndex',
205
+ 'parentRenderLayer',
206
+ 'relativeRenderGroupDepth',
207
+ 'renderPipeId',
208
+ 'instructionSet',
209
+ 'childrenToUpdate',
210
+ 'childrenRenderablesToUpdate',
211
+ 'effects',
212
+ 'filters',
213
+ 'mask',
214
+ 'bounds',
215
+ 'boundsArea',
216
+ 'visualBounds',
217
+ 'gpuData',
218
+ 'texture',
219
+ 'events',
220
+ 'eventsCount',
221
+ 'updateFlags',
147
222
  ]);
148
- function labelFromHolder(host, view, seen) {
149
- if (seen.has(host))
223
+ function labelFromHolder(host, view, search) {
224
+ if (search.seen.has(host))
150
225
  return undefined;
151
- seen.add(host);
152
- const asBag = nameInBag(host, view, undefined, seen);
226
+ search.seen.add(host);
227
+ const asBag = nameInBag(host, view, undefined, search);
153
228
  if (asBag)
154
229
  return asBag;
155
230
  for (const [key, value] of Object.entries(host)) {
231
+ if (!spend(search))
232
+ return undefined;
156
233
  if (!isGameFieldKey(key))
157
234
  continue;
158
235
  const named = nameIfHoldsView(host, key, value, view);
159
236
  if (named)
160
237
  return named;
161
- const nested = nameInBag(value, view, key, seen);
238
+ const nested = nameInBag(value, view, key, search);
162
239
  if (nested)
163
240
  return nested;
164
241
  }
165
242
  return undefined;
166
243
  }
167
- function nameInBag(value, view, fieldKey, seen) {
244
+ function nameInBag(value, view, fieldKey, search) {
168
245
  const entries = bagEntries(value);
169
246
  if (!entries)
170
247
  return undefined;
171
248
  for (const [itemKey, item] of entries) {
172
- const named = nameBagItem(value, item, usableBagKey(itemKey) ?? fieldKey, view, seen);
249
+ if (!spend(search))
250
+ return undefined;
251
+ const named = nameBagItem(value, item, usableBagKey(itemKey) ?? fieldKey, view, search);
173
252
  if (named)
174
253
  return named;
175
254
  }
176
255
  return undefined;
177
256
  }
178
- function nameBagItem(bag, item, key, view, seen) {
257
+ function nameBagItem(bag, item, key, view, search) {
179
258
  if (key) {
180
259
  const named = nameIfHoldsView(item && typeof item === 'object' ? item : bag, key, item, view);
181
260
  if (named)
@@ -187,7 +266,7 @@ function nameBagItem(bag, item, key, view, seen) {
187
266
  return named;
188
267
  }
189
268
  if (item && typeof item === 'object' && !isPixiDisplay(item)) {
190
- return labelFromHolder(item, view, seen);
269
+ return labelFromHolder(item, view, search);
191
270
  }
192
271
  return undefined;
193
272
  }
@@ -196,6 +275,29 @@ function usableBagKey(key) {
196
275
  return undefined;
197
276
  return isGameFieldKey(key) || key.includes('-') ? (fieldLabel(key) ?? key) : undefined;
198
277
  }
278
+ function* indexedBagEntries(value) {
279
+ for (let index = 0; index < value.length; index++)
280
+ yield [String(index), value[index]];
281
+ }
282
+ function* mapBagEntries(value) {
283
+ for (const [key, item] of value.entries())
284
+ yield [String(key), item];
285
+ }
286
+ function* setBagEntries(value) {
287
+ let index = 0;
288
+ for (const item of value) {
289
+ yield [String(index), item];
290
+ index += 1;
291
+ }
292
+ }
293
+ /**
294
+ * A bag's entries, LAZILY.
295
+ *
296
+ * Lazy because the caller is budgeted ({@link OWNER_SEARCH_BUDGET}) and must be
297
+ * able to stop: materializing the pairs first made a 20 000-element array cost
298
+ * 20 000 allocations before the first one was even looked at, which is a cost
299
+ * no budget above it can decline.
300
+ */
199
301
  function bagEntries(value) {
200
302
  if (!value || typeof value !== 'object')
201
303
  return undefined;
@@ -203,18 +305,13 @@ function bagEntries(value) {
203
305
  // their overridable `map`: one shipped Pixi game returns its elements rather
204
306
  // than `[key, value]` pairs there, which made hierarchy labeling throw while
205
307
  // destructuring the result. The authoring seam treats game objects as
206
- // untrusted observations, so normalize with indexed reads into our own array.
207
- if (Array.isArray(value)) {
208
- const entries = [];
209
- for (let index = 0; index < value.length; index++) {
210
- entries.push([String(index), value[index]]);
211
- }
212
- return entries;
213
- }
308
+ // untrusted observations, so normalize with indexed reads of our own.
309
+ if (Array.isArray(value))
310
+ return indexedBagEntries(value);
214
311
  if (value instanceof Map)
215
- return [...value.entries()].map(([key, item]) => [String(key), item]);
312
+ return mapBagEntries(value);
216
313
  if (value instanceof Set)
217
- return [...value].map((item, index) => [String(index), item]);
314
+ return setBagEntries(value);
218
315
  if (Object.getPrototypeOf(value) !== Object.prototype)
219
316
  return undefined;
220
317
  return Object.entries(value);
@@ -232,16 +329,27 @@ function nameIfHoldsView(host, key, value, view) {
232
329
  }
233
330
  return undefined;
234
331
  }
235
- function findHeldOwner(held, view, depth, seen) {
236
- const direct = labelFromHolder(held, view, seen);
332
+ function findHeldOwner(held, view, depth, search) {
333
+ const direct = labelFromHolder(held, view, search);
237
334
  if (direct)
238
335
  return direct;
239
336
  if (depth <= 0)
240
337
  return undefined;
241
- for (const value of Object.values(held)) {
338
+ // BY KEY, exactly like `labelFromHolder`'s own scan. Iterating `Object.values`
339
+ // here was the leak that made a hierarchy read quadratic: `parentRenderGroup`
340
+ // sits on EVERY container once a world has rendered, and descending into it
341
+ // reaches `childrenToUpdate[depth].list` — an array of every node in the
342
+ // tree — so the search for one node's owner walked the whole tree, for every
343
+ // unlabeled node. `isGameFieldKey` already knows which keys belong to Pixi
344
+ // rather than to the game; this scan simply has to ask it too.
345
+ for (const [key, value] of Object.entries(held)) {
346
+ if (!spend(search))
347
+ return undefined;
348
+ if (!isGameFieldKey(key))
349
+ continue;
242
350
  if (!value || typeof value !== 'object' || isPixiDisplay(value))
243
351
  continue;
244
- const found = findHeldOwner(value, view, depth - 1, seen);
352
+ const found = findHeldOwner(value, view, depth - 1, search);
245
353
  if (found)
246
354
  return found;
247
355
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.18",
5
+ "version": "0.5.19",
6
6
  "description": "Readable TypeScript game engine and universal host for Three.js, PixiJS, and React games.",
7
7
  "keywords": [
8
8
  "game-engine",
@@ -159,43 +159,125 @@ function isPixiDisplay(value: object): boolean {
159
159
  * never reached `_cannon` inside the budget.
160
160
  */
161
161
  function viewOwnerLabel(view: Container): string | undefined {
162
- const seen = new WeakSet<object>();
162
+ const search: OwnerSearch = { seen: new WeakSet<object>(), budget: OWNER_SEARCH_BUDGET };
163
163
  let host: unknown = view.parent;
164
164
  for (let hops = 0; hops < 8 && host && typeof host === 'object'; hops++) {
165
- const found = labelFromHolder(host, view, seen) ?? findHeldOwner(host, view, 2, seen);
165
+ if (search.budget <= 0) return undefined;
166
+ const found = labelFromHolder(host, view, search) ?? findHeldOwner(host, view, 2, search);
166
167
  if (found) return found;
167
168
  host = (host as { parent?: unknown }).parent;
168
169
  }
169
170
  return undefined;
170
171
  }
171
172
 
173
+ /**
174
+ * THE CEILING ON ONE NODE'S OWNER HUNT — what keeps a hierarchy read LINEAR in
175
+ * the size of the tree instead of quadratic in it.
176
+ *
177
+ * The hunt below is a search of the GAME's object graph, and a game's object
178
+ * graph is small: `this._cannon`, `allSystems.get('hud')`, `_decor[3]` are all
179
+ * found within a few dozen examinations. What is not small is anything the
180
+ * search can leak into — Pixi's own live render graph is the measured one, and
181
+ * naming a key blacklist is a fix for the leak you already found, not for the
182
+ * class. So every examination is charged against this budget and the search
183
+ * simply gives up when it runs out: the node then falls through to its texture
184
+ * alias / constructor / kind name, which is the same honest answer it gets
185
+ * today whenever the hunt finds nothing.
186
+ *
187
+ * MEASURED (2026-08-20, `packages/editor/scripts/scale-harness`): entering play
188
+ * on a 20 000-node `@pixi/react` world blocked the main thread for 199.5
189
+ * SECONDS in one synchronous run, 69% of all profiler samples inside this
190
+ * search, because `parentRenderGroup` reaches an array of every node in the
191
+ * tree and the search walked it once per unlabeled node. `vgai play`,
192
+ * `screenshot` and `stop` all timed out against a tab that was heartbeating
193
+ * normally the whole time.
194
+ */
195
+ const OWNER_SEARCH_BUDGET = 2_000;
196
+
197
+ /** One node's owner hunt: what it has already looked at, and what it has left. */
198
+ interface OwnerSearch {
199
+ readonly seen: WeakSet<object>;
200
+ /** Counts DOWN. Zero ends the hunt for this node, wherever it has reached. */
201
+ budget: number;
202
+ }
203
+
204
+ /** Charge one examination. `false` means the hunt is over. */
205
+ function spend(search: OwnerSearch): boolean {
206
+ if (search.budget <= 0) return false;
207
+ search.budget -= 1;
208
+ return true;
209
+ }
210
+
172
211
  function isGameFieldKey(key: string): boolean {
173
212
  // Skip display-list indices (`0`, `1`) and Pixi internals (`children`).
174
- return /^_?[A-Za-z][A-Za-zA-Z0-9]*$/.test(key) && !PIXI_INTERNAL_KEY.has(key);
213
+ // The membership test is on the UNDERSCORED-STRIPPED key because that is how
214
+ // Pixi v8 spells most of them (`_position`, `_bounds`, `_texture`), and a
215
+ // set listing the bare names was silently matching none of those.
216
+ if (!/^_?[A-Za-z][A-Za-zA-Z0-9]*$/.test(key)) return false;
217
+ return !PIXI_INTERNAL_KEY.has(key.replace(/^_+/, ''));
175
218
  }
176
219
 
220
+ /**
221
+ * Field names that belong to Pixi (or to its EventEmitter base), never to the
222
+ * game. Read off a live `pixi.js` v8 `Container`/`Sprite`'s own keys, so this
223
+ * is a transcription rather than a guess — but it is the OPTIMIZATION, not the
224
+ * correctness boundary: {@link OWNER_SEARCH_BUDGET} is what bounds the search
225
+ * on a Pixi version whose internals this list has never heard of. Spending the
226
+ * budget on plausible game fields instead of on the render graph is what keeps
227
+ * the heuristic's hit rate while it is bounded.
228
+ *
229
+ * `view` is deliberately absent: `.view` is the very field the hunt is looking
230
+ * for.
231
+ */
177
232
  const PIXI_INTERNAL_KEY = new Set([
178
233
  'parent',
179
234
  'children',
180
235
  'transform',
181
236
  'position',
237
+ 'origin',
182
238
  'scale',
183
239
  'pivot',
184
240
  'skew',
241
+ 'anchor',
185
242
  'worldTransform',
186
243
  'localTransform',
244
+ 'groupTransform',
245
+ 'relativeGroupTransform',
246
+ // The render graph — the measured leak. `parentRenderGroup` is on every
247
+ // container once a world has rendered and reaches every node in the tree.
248
+ 'renderGroup',
249
+ 'parentRenderGroup',
250
+ 'parentRenderGroupIndex',
251
+ 'parentRenderLayer',
252
+ 'relativeRenderGroupDepth',
253
+ 'renderPipeId',
254
+ 'instructionSet',
255
+ 'childrenToUpdate',
256
+ 'childrenRenderablesToUpdate',
257
+ 'effects',
258
+ 'filters',
259
+ 'mask',
260
+ 'bounds',
261
+ 'boundsArea',
262
+ 'visualBounds',
263
+ 'gpuData',
264
+ 'texture',
265
+ 'events',
266
+ 'eventsCount',
267
+ 'updateFlags',
187
268
  ]);
188
269
 
189
- function labelFromHolder(host: object, view: Container, seen: WeakSet<object>): string | undefined {
190
- if (seen.has(host)) return undefined;
191
- seen.add(host);
192
- const asBag = nameInBag(host, view, undefined, seen);
270
+ function labelFromHolder(host: object, view: Container, search: OwnerSearch): string | undefined {
271
+ if (search.seen.has(host)) return undefined;
272
+ search.seen.add(host);
273
+ const asBag = nameInBag(host, view, undefined, search);
193
274
  if (asBag) return asBag;
194
275
  for (const [key, value] of Object.entries(host as Record<string, unknown>)) {
276
+ if (!spend(search)) return undefined;
195
277
  if (!isGameFieldKey(key)) continue;
196
278
  const named = nameIfHoldsView(host, key, value, view);
197
279
  if (named) return named;
198
- const nested = nameInBag(value, view, key, seen);
280
+ const nested = nameInBag(value, view, key, search);
199
281
  if (nested) return nested;
200
282
  }
201
283
  return undefined;
@@ -205,12 +287,13 @@ function nameInBag(
205
287
  value: unknown,
206
288
  view: Container,
207
289
  fieldKey: string | undefined,
208
- seen: WeakSet<object>,
290
+ search: OwnerSearch,
209
291
  ): string | undefined {
210
292
  const entries = bagEntries(value);
211
293
  if (!entries) return undefined;
212
294
  for (const [itemKey, item] of entries) {
213
- const named = nameBagItem(value, item, usableBagKey(itemKey) ?? fieldKey, view, seen);
295
+ if (!spend(search)) return undefined;
296
+ const named = nameBagItem(value, item, usableBagKey(itemKey) ?? fieldKey, view, search);
214
297
  if (named) return named;
215
298
  }
216
299
  return undefined;
@@ -221,7 +304,7 @@ function nameBagItem(
221
304
  item: unknown,
222
305
  key: string | undefined,
223
306
  view: Container,
224
- seen: WeakSet<object>,
307
+ search: OwnerSearch,
225
308
  ): string | undefined {
226
309
  if (key) {
227
310
  const named = nameIfHoldsView(
@@ -236,7 +319,7 @@ function nameBagItem(
236
319
  if (named) return named;
237
320
  }
238
321
  if (item && typeof item === 'object' && !isPixiDisplay(item)) {
239
- return labelFromHolder(item, view, seen);
322
+ return labelFromHolder(item, view, search);
240
323
  }
241
324
  return undefined;
242
325
  }
@@ -246,22 +329,40 @@ function usableBagKey(key: string): string | undefined {
246
329
  return isGameFieldKey(key) || key.includes('-') ? (fieldLabel(key) ?? key) : undefined;
247
330
  }
248
331
 
249
- function bagEntries(value: unknown): readonly [string, unknown][] | undefined {
332
+ function* indexedBagEntries(value: ArrayLike<unknown>): Generator<[string, unknown]> {
333
+ for (let index = 0; index < value.length; index++) yield [String(index), value[index]];
334
+ }
335
+
336
+ function* mapBagEntries(value: Map<unknown, unknown>): Generator<[string, unknown]> {
337
+ for (const [key, item] of value.entries()) yield [String(key), item];
338
+ }
339
+
340
+ function* setBagEntries(value: Set<unknown>): Generator<[string, unknown]> {
341
+ let index = 0;
342
+ for (const item of value) {
343
+ yield [String(index), item];
344
+ index += 1;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * A bag's entries, LAZILY.
350
+ *
351
+ * Lazy because the caller is budgeted ({@link OWNER_SEARCH_BUDGET}) and must be
352
+ * able to stop: materializing the pairs first made a 20 000-element array cost
353
+ * 20 000 allocations before the first one was even looked at, which is a cost
354
+ * no budget above it can decline.
355
+ */
356
+ function bagEntries(value: unknown): Iterable<readonly [string, unknown]> | undefined {
250
357
  if (!value || typeof value !== 'object') return undefined;
251
358
  // Game arrays are allowed to be exotic subclasses. Do not dispatch through
252
359
  // their overridable `map`: one shipped Pixi game returns its elements rather
253
360
  // than `[key, value]` pairs there, which made hierarchy labeling throw while
254
361
  // destructuring the result. The authoring seam treats game objects as
255
- // untrusted observations, so normalize with indexed reads into our own array.
256
- if (Array.isArray(value)) {
257
- const entries: [string, unknown][] = [];
258
- for (let index = 0; index < value.length; index++) {
259
- entries.push([String(index), value[index]]);
260
- }
261
- return entries;
262
- }
263
- if (value instanceof Map) return [...value.entries()].map(([key, item]) => [String(key), item]);
264
- if (value instanceof Set) return [...value].map((item, index) => [String(index), item]);
362
+ // untrusted observations, so normalize with indexed reads of our own.
363
+ if (Array.isArray(value)) return indexedBagEntries(value as ArrayLike<unknown>);
364
+ if (value instanceof Map) return mapBagEntries(value);
365
+ if (value instanceof Set) return setBagEntries(value);
265
366
  if (Object.getPrototypeOf(value) !== Object.prototype) return undefined;
266
367
  return Object.entries(value as Record<string, unknown>);
267
368
  }
@@ -289,14 +390,23 @@ function findHeldOwner(
289
390
  held: object,
290
391
  view: Container,
291
392
  depth: number,
292
- seen: WeakSet<object>,
393
+ search: OwnerSearch,
293
394
  ): string | undefined {
294
- const direct = labelFromHolder(held, view, seen);
395
+ const direct = labelFromHolder(held, view, search);
295
396
  if (direct) return direct;
296
397
  if (depth <= 0) return undefined;
297
- for (const value of Object.values(held as Record<string, unknown>)) {
398
+ // BY KEY, exactly like `labelFromHolder`'s own scan. Iterating `Object.values`
399
+ // here was the leak that made a hierarchy read quadratic: `parentRenderGroup`
400
+ // sits on EVERY container once a world has rendered, and descending into it
401
+ // reaches `childrenToUpdate[depth].list` — an array of every node in the
402
+ // tree — so the search for one node's owner walked the whole tree, for every
403
+ // unlabeled node. `isGameFieldKey` already knows which keys belong to Pixi
404
+ // rather than to the game; this scan simply has to ask it too.
405
+ for (const [key, value] of Object.entries(held as Record<string, unknown>)) {
406
+ if (!spend(search)) return undefined;
407
+ if (!isGameFieldKey(key)) continue;
298
408
  if (!value || typeof value !== 'object' || isPixiDisplay(value)) continue;
299
- const found = findHeldOwner(value, view, depth - 1, seen);
409
+ const found = findHeldOwner(value, view, depth - 1, search);
300
410
  if (found) return found;
301
411
  }
302
412
  return undefined;