@gnsx/three 0.184.18 → 0.184.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gnsx/three",
3
- "version": "0.184.18",
3
+ "version": "0.184.20",
4
4
  "description": "JavaScript 3D library",
5
5
  "type": "module",
6
6
  "main": "./build/three.cjs",
@@ -108,7 +108,7 @@
108
108
  "jsdoc": "^4.0.5",
109
109
  "magic-string": "^0.30.0",
110
110
  "pngjs": "^7.0.0",
111
- "puppeteer": "^24.40.0",
111
+ "puppeteer": "^25.1.0",
112
112
  "qunit": "^2.19.4",
113
113
  "rollup": "^4.6.0",
114
114
  "three": "file:.",
@@ -9,14 +9,22 @@
9
9
  * __gnsx_profiler.downloadTrace() // download gnsx-trace.json for Speedscope / Perfetto
10
10
  * __gnsx_profiler.reset() // clear samples and trace
11
11
  * __gnsx_profiler.disable()
12
+ *
13
+ * Chrome trace: `tid=1` is synchronous work; `@profile` on async methods records the
14
+ * promise lifetime on `tid=2` so long async does not flatten the main row. For manual
15
+ * spans, use `beginSpan` / `endSpan` with `{ asyncTimeline: true }` in a `.finally()`.
12
16
  */
13
17
 
14
18
  const RING_SIZE = 120;
15
19
  const FRAME_BUDGET_MS = 1000 / 60;
16
- /** Max events in the trace log (~50s at 60fps with 8 labels). */
17
- const MAX_TRACE_EVENTS = 50_000;
20
+ const TRACE_TID_MAIN = 1;
21
+ const TRACE_TID_ASYNC = 2;
18
22
  const NOOP = () => {};
19
23
 
24
+ const DUMMY_SPAN = Object.freeze( { label: '', t0: 0, _seq: 0 } );
25
+ const NOOP_BEGIN_SPAN = () => DUMMY_SPAN;
26
+ const NOOP_END_SPAN = () => {};
27
+
20
28
  class ProfilerServiceClass {
21
29
 
22
30
  constructor() {
@@ -24,24 +32,40 @@ class ProfilerServiceClass {
24
32
  this._profile = 'full';
25
33
  this._enabled = false;
26
34
 
27
- /** @type {Map<string, Float64Array>} */
35
+ /** @type {Map<string, Float64Array>} Inclusive time ring buffers (one per label). */
28
36
  this.buffers = new Map();
29
37
  /** @type {Map<string, number>} */
30
38
  this.cursors = new Map();
31
39
  /** @type {Map<string, number>} */
32
40
  this.counts = new Map();
33
- /** @type {Map<string, Array<{ startTime: number, startMark: string, traced: boolean }>>} */
41
+ /** @type {Map<string, Array<{ startTime: number, startMark: string }>>} */
34
42
  this.marks = new Map();
35
43
  /** @type {ChromeTraceEvent[]} */
36
44
  this.traceEvents = [];
37
45
  this.traceStartTime = 0;
38
46
  this._markId = 0;
39
47
 
48
+ /** @type {Map<string, Float64Array>} Exclusive (self) time ring buffers (one per label). */
49
+ this.selfBuffers = new Map();
50
+ /** @type {Map<string, number>} */
51
+ this.selfCursors = new Map();
52
+ /** @type {Map<string, number>} */
53
+ this.selfCounts = new Map();
54
+ /**
55
+ * Global call stack tracking nesting across all labels for exclusive-time computation.
56
+ * @type {Array<{ label: string, childTime: number }>}
57
+ */
58
+ this.callStack = [];
59
+ /** @type {Map<string, number>} Total (uncapped) invocation count since last reset, for calls-per-frame. */
60
+ this.invocations = new Map();
61
+
40
62
  if ( this._enabled ) {
41
63
 
42
64
  this.traceStartTime = performance.now();
43
65
  this.begin = this._beginImpl.bind( this );
44
66
  this.end = this._endImpl.bind( this );
67
+ this.beginSpan = this._beginSpanImpl.bind( this );
68
+ this.endSpan = this._endSpanImpl.bind( this );
45
69
  this._exposeGlobal();
46
70
  console.log( `[ProfilerService] auto-started from env (profile: ${this._profile})` );
47
71
 
@@ -49,6 +73,8 @@ class ProfilerServiceClass {
49
73
 
50
74
  this.begin = NOOP;
51
75
  this.end = NOOP;
76
+ this.beginSpan = NOOP_BEGIN_SPAN;
77
+ this.endSpan = NOOP_END_SPAN;
52
78
 
53
79
  }
54
80
 
@@ -80,6 +106,8 @@ class ProfilerServiceClass {
80
106
  this._enabled = true;
81
107
  this.begin = this._beginImpl.bind( this );
82
108
  this.end = this._endImpl.bind( this );
109
+ this.beginSpan = this._beginSpanImpl.bind( this );
110
+ this.endSpan = this._endSpanImpl.bind( this );
83
111
  this._exposeGlobal();
84
112
  console.log( `[ProfilerService] enabled (profile: ${this._profile}) — call __gnsx_profiler.report() or downloadTrace() from the console` );
85
113
 
@@ -91,6 +119,8 @@ class ProfilerServiceClass {
91
119
  this._enabled = false;
92
120
  this.begin = NOOP;
93
121
  this.end = NOOP;
122
+ this.beginSpan = NOOP_BEGIN_SPAN;
123
+ this.endSpan = NOOP_END_SPAN;
94
124
  console.log( '[ProfilerService] disabled' );
95
125
 
96
126
  }
@@ -126,22 +156,11 @@ class ProfilerServiceClass {
126
156
 
127
157
  }
128
158
 
129
- const traced = this._profile === 'full' && this.traceEvents.length < MAX_TRACE_EVENTS;
130
- if ( traced ) {
131
-
132
- this.traceEvents.push( {
133
- name: label,
134
- ph: 'B',
135
- ts: this._getTraceTimestamp( startTime ),
136
- pid: 1,
137
- tid: 1,
138
- cat: 'gnsx',
139
- } );
140
-
141
- }
142
-
143
159
  performance.mark( startMark );
144
- stack.push( { startTime, startMark, traced } );
160
+ stack.push( { startTime, startMark } );
161
+
162
+ // Push onto the global call stack for exclusive-time tracking.
163
+ this.callStack.push( { label, childTime: 0 } );
145
164
 
146
165
  }
147
166
 
@@ -156,13 +175,61 @@ class ProfilerServiceClass {
156
175
  if ( stack.length === 0 ) this.marks.delete( label );
157
176
 
158
177
  const now = performance.now();
159
- const { startTime, startMark, traced } = mark;
178
+ const { startTime, startMark } = mark;
160
179
  const duration = now - startTime;
161
180
  const endMark = `gnsx:${label}:end:${++ this._markId}`;
162
181
 
163
182
  performance.mark( endMark );
164
183
  performance.measure( `gnsx:${label}`, startMark, endMark );
165
184
 
185
+ this._commitDurationSample( label, startTime, duration, TRACE_TID_MAIN );
186
+
187
+ }
188
+
189
+ /**
190
+ * Per-invocation span start (pair with {@link ProfilerServiceClass#endSpan}).
191
+ * Safe for concurrent async with the same label.
192
+ *
193
+ * @param {string} label
194
+ * @return {import('./ProfilerService.js').SpanHandle}
195
+ */
196
+ _beginSpanImpl( label ) {
197
+
198
+ const seq = ++ this._markId;
199
+ const t0 = performance.now();
200
+ const startMark = `gnsx:${label}:s${seq}:start`;
201
+ performance.mark( startMark );
202
+ return { label, t0, _seq: seq, _startMark: startMark };
203
+
204
+ }
205
+
206
+ /**
207
+ * @param {import('./ProfilerService.js').SpanHandle} handle
208
+ * @param {import('./ProfilerService.js').EndSpanOptions} [opts]
209
+ */
210
+ _endSpanImpl( handle, opts ) {
211
+
212
+ if ( handle._seq === 0 ) return;
213
+
214
+ const now = performance.now();
215
+ const duration = now - handle.t0;
216
+ const endMark = `gnsx:${handle.label}:s${handle._seq}:end`;
217
+ performance.mark( endMark );
218
+ performance.measure( `gnsx:${handle.label}#${handle._seq}`, handle._startMark, endMark );
219
+
220
+ const traceTid = opts?.asyncTimeline === true ? TRACE_TID_ASYNC : TRACE_TID_MAIN;
221
+ this._commitDurationSample( handle.label, handle.t0, duration, traceTid );
222
+
223
+ }
224
+
225
+ /**
226
+ * @param {string} label
227
+ * @param {number} startTime
228
+ * @param {number} durationMs
229
+ * @param {typeof TRACE_TID_MAIN|typeof TRACE_TID_ASYNC} traceTid
230
+ */
231
+ _commitDurationSample( label, startTime, durationMs, traceTid ) {
232
+
166
233
  let buffer = this.buffers.get( label );
167
234
  if ( ! buffer ) {
168
235
 
@@ -174,18 +241,56 @@ class ProfilerServiceClass {
174
241
  }
175
242
 
176
243
  const cursor = this.cursors.get( label );
177
- buffer[ cursor ] = duration;
244
+ buffer[ cursor ] = durationMs;
178
245
  this.cursors.set( label, ( cursor + 1 ) % RING_SIZE );
179
246
  this.counts.set( label, Math.min( ( this.counts.get( label ) + 1 ), RING_SIZE ) );
180
247
 
181
- if ( traced ) {
248
+ // Track total invocations (uncapped) for calls-per-frame computation.
249
+ this.invocations.set( label, ( this.invocations.get( label ) ?? 0 ) + 1 );
182
250
 
251
+ // Exclusive (self) time via the global call stack.
252
+ const top = this.callStack.length > 0 ? this.callStack[ this.callStack.length - 1 ] : undefined;
253
+ if ( top !== undefined && top.label === label ) {
254
+
255
+ this.callStack.pop();
256
+ const selfTime = Math.max( 0, durationMs - top.childTime );
257
+
258
+ let selfBuffer = this.selfBuffers.get( label );
259
+ if ( ! selfBuffer ) {
260
+
261
+ selfBuffer = new Float64Array( RING_SIZE );
262
+ this.selfBuffers.set( label, selfBuffer );
263
+ this.selfCursors.set( label, 0 );
264
+ this.selfCounts.set( label, 0 );
265
+
266
+ }
267
+
268
+ const selfCursor = this.selfCursors.get( label );
269
+ selfBuffer[ selfCursor ] = selfTime;
270
+ this.selfCursors.set( label, ( selfCursor + 1 ) % RING_SIZE );
271
+ this.selfCounts.set( label, Math.min( ( this.selfCounts.get( label ) + 1 ), RING_SIZE ) );
272
+
273
+ // Propagate inclusive duration to the parent scope's child accumulator.
274
+ const parent = this.callStack.length > 0 ? this.callStack[ this.callStack.length - 1 ] : undefined;
275
+ if ( parent !== undefined ) parent.childTime += durationMs;
276
+
277
+ } else {
278
+
279
+ // Label mismatch — likely async interleaving. Reset to avoid corruption.
280
+ this.callStack.length = 0;
281
+
282
+ }
283
+
284
+ if ( traceTid ) {
285
+
286
+ const traceName = traceTid === TRACE_TID_ASYNC ? `${label} (promise)` : label;
183
287
  this.traceEvents.push( {
184
- name: label,
185
- ph: 'E',
186
- ts: this._getTraceTimestamp( now ),
288
+ name: traceName,
289
+ ph: 'X',
290
+ ts: Math.round( this._getTraceTimestamp( startTime ) ),
291
+ dur: Math.max( 1, Math.round( durationMs * 1000 ) ),
187
292
  pid: 1,
188
- tid: 1,
293
+ tid: traceTid,
189
294
  cat: 'gnsx',
190
295
  } );
191
296
 
@@ -220,6 +325,23 @@ class ProfilerServiceClass {
220
325
  const sorted = [ ...samples ].sort( ( a, b ) => a - b );
221
326
  const avg = sorted.reduce( ( a, b ) => a + b, 0 ) / sorted.length;
222
327
 
328
+ // Exclusive (self) time stats.
329
+ const selfCount = this.selfCounts.get( label ) ?? 0;
330
+ let selfAvg, selfMin, selfMax, selfP95, selfFrameBudget;
331
+ if ( selfCount > 0 ) {
332
+
333
+ const selfBuffer = this.selfBuffers.get( label );
334
+ const selfSamples = [ ...( selfCount < RING_SIZE
335
+ ? selfBuffer.subarray( 0, selfCount )
336
+ : selfBuffer ) ].sort( ( a, b ) => a - b );
337
+ selfAvg = selfSamples.reduce( ( a, b ) => a + b, 0 ) / selfSamples.length;
338
+ selfMin = selfSamples[ 0 ];
339
+ selfMax = selfSamples[ selfSamples.length - 1 ];
340
+ selfP95 = selfSamples[ Math.floor( selfSamples.length * 0.95 ) ];
341
+ selfFrameBudget = ( selfAvg / FRAME_BUDGET_MS ) * 100;
342
+
343
+ }
344
+
223
345
  return {
224
346
  label,
225
347
  samples: samples.length,
@@ -228,6 +350,12 @@ class ProfilerServiceClass {
228
350
  max: sorted[ sorted.length - 1 ],
229
351
  p95: sorted[ Math.floor( sorted.length * 0.95 ) ],
230
352
  frameBudget: ( avg / FRAME_BUDGET_MS ) * 100,
353
+ totalInvocations: this.invocations.get( label ) ?? 0,
354
+ selfAvg,
355
+ selfMin,
356
+ selfMax,
357
+ selfP95,
358
+ selfFrameBudget,
231
359
  };
232
360
 
233
361
  }
@@ -273,13 +401,53 @@ class ProfilerServiceClass {
273
401
  */
274
402
  exportChromeTrace() {
275
403
 
404
+ const slices = [ ...this.traceEvents ];
405
+ slices.sort( ( a, b ) => {
406
+
407
+ if ( a.ts !== b.ts ) return a.ts - b.ts;
408
+ if ( a.tid !== b.tid ) return a.tid - b.tid;
409
+ return a.name.localeCompare( b.name );
410
+
411
+ } );
412
+ const hasAsync = slices.some( e => e.tid === TRACE_TID_ASYNC );
413
+ /** @type {import('./ProfilerService.js').ChromeTraceMetadataEvent[]} */
414
+ const prefix = [
415
+ {
416
+ cat: '__metadata',
417
+ name: 'process_name',
418
+ ph: 'M',
419
+ pid: 1,
420
+ tid: 0,
421
+ ts: 0,
422
+ args: { name: 'Genesys Profiler' },
423
+ },
424
+ {
425
+ cat: '__metadata',
426
+ name: 'thread_name',
427
+ ph: 'M',
428
+ pid: 1,
429
+ tid: TRACE_TID_MAIN,
430
+ ts: 0,
431
+ args: { name: 'Main thread' },
432
+ },
433
+ ];
434
+ if ( hasAsync ) {
435
+
436
+ prefix.push( {
437
+ cat: '__metadata',
438
+ name: 'thread_name',
439
+ ph: 'M',
440
+ pid: 1,
441
+ tid: TRACE_TID_ASYNC,
442
+ ts: 0,
443
+ args: { name: 'Async (promise lifetime)' },
444
+ } );
445
+
446
+ }
447
+
276
448
  return {
277
449
  displayTimeUnit: 'ms',
278
- traceEvents: [
279
- { name: 'process_name', ph: 'M', pid: 1, args: { name: 'Genesys Profiler' } },
280
- { name: 'thread_name', ph: 'M', pid: 1, tid: 1, args: { name: 'Main Thread' } },
281
- ...this.traceEvents,
282
- ],
450
+ traceEvents: [ ...prefix, ...slices ],
283
451
  };
284
452
 
285
453
  }
@@ -350,6 +518,11 @@ class ProfilerServiceClass {
350
518
  this.traceEvents = [];
351
519
  this.traceStartTime = performance.now();
352
520
  this._markId = 0;
521
+ this.selfBuffers.clear();
522
+ this.selfCursors.clear();
523
+ this.selfCounts.clear();
524
+ this.callStack.length = 0;
525
+ this.invocations.clear();
353
526
 
354
527
  }
355
528
 
@@ -371,24 +544,46 @@ class ProfilerServiceClass {
371
544
  * @property {number} max
372
545
  * @property {number} p95
373
546
  * @property {number} frameBudget Percentage of a 60 fps frame budget (16.67 ms)
547
+ * @property {number} totalInvocations Total (uncapped) call count since last reset, for calls-per-frame computation.
548
+ * @property {number|undefined} selfAvg Exclusive (self) avg ms — inclusive time minus child scope time.
549
+ * @property {number|undefined} selfMin
550
+ * @property {number|undefined} selfMax
551
+ * @property {number|undefined} selfP95
552
+ * @property {number|undefined} selfFrameBudget Exclusive time as percentage of a 60 fps frame budget.
553
+ */
554
+
555
+ /**
556
+ * @typedef {Object} SpanHandle
557
+ * @property {string} label
558
+ * @property {number} t0
559
+ * @property {number} _seq
560
+ * @property {string} _startMark
561
+ */
562
+
563
+ /**
564
+ * @typedef {Object} EndSpanOptions
565
+ * @property {boolean} [asyncTimeline] When true, trace slice uses `tid=2` (virtual async row).
374
566
  */
375
567
 
376
568
  /**
377
569
  * @typedef {Object} ChromeTraceEvent
378
570
  * @property {string} name
379
- * @property {'B'|'E'} ph
571
+ * @property {'X'} ph
380
572
  * @property {number} ts
573
+ * @property {number} dur
381
574
  * @property {1} pid
382
- * @property {1} tid
575
+ * @property {number} tid
383
576
  * @property {'gnsx'} cat
384
577
  */
385
578
 
386
579
  /**
387
580
  * @typedef {Object} ChromeTraceMetadataEvent
581
+ * @property {'__metadata'} cat
388
582
  * @property {'process_name'|'thread_name'} name
389
583
  * @property {'M'} ph
390
584
  * @property {1} pid
391
- * @property {1} [tid]
585
+ * @property {number} [tid]
586
+ * @property {0} ts
392
587
  * @property {{ name: string }} args
393
588
  */
394
589
 
@@ -404,28 +599,38 @@ class ProfilerServiceClass {
404
599
 
405
600
  const ProfilerService = new ProfilerServiceClass();
406
601
 
602
+ function isThenable( x ) {
603
+
604
+ return (
605
+ ( typeof x === 'object' || typeof x === 'function' ) &&
606
+ x !== null &&
607
+ typeof x.then === 'function'
608
+ );
609
+
610
+ }
611
+
407
612
  function applyProfileToMethod( label, descriptor ) {
408
613
 
409
614
  const original = descriptor.value;
410
615
 
411
616
  function profiled( ...args ) {
412
617
 
413
- ProfilerService.begin( label );
618
+ const span = ProfilerService.beginSpan( label );
414
619
  try {
415
620
 
416
621
  const result = original.apply( this, args );
417
- if ( result instanceof Promise ) {
622
+ if ( isThenable( result ) ) {
418
623
 
419
- return result.finally( () => ProfilerService.end( label ) );
624
+ return Promise.resolve( result ).finally( () => ProfilerService.endSpan( span, { asyncTimeline: true } ) );
420
625
 
421
626
  }
422
627
 
423
- ProfilerService.end( label );
628
+ ProfilerService.endSpan( span );
424
629
  return result;
425
630
 
426
631
  } catch ( error ) {
427
632
 
428
- ProfilerService.end( label );
633
+ ProfilerService.endSpan( span );
429
634
  throw error;
430
635
 
431
636
  }
@@ -439,14 +644,39 @@ function applyProfileToMethod( label, descriptor ) {
439
644
 
440
645
  /**
441
646
  * Method decorator that profiles the decorated method.
442
- * The label is automatically set to `ClassName.methodName`.
647
+ * Default label: `ClassName.methodName`. Pass a custom tag via `@profile('My tag')`.
443
648
  *
649
+ * @param {Object|string} [targetOrTag]
650
+ * @param {string|symbol} [propertyKey]
651
+ * @param {PropertyDescriptor} [descriptor]
652
+ * @return {PropertyDescriptor|function(Object, string|symbol, PropertyDescriptor): PropertyDescriptor}
653
+ */
654
+ function profile( targetOrTag, propertyKey, descriptor ) {
655
+
656
+ if ( arguments.length === 0 ) {
657
+
658
+ return defaultProfileDecorator;
659
+
660
+ }
661
+
662
+ if ( arguments.length === 1 && typeof targetOrTag === 'string' ) {
663
+
664
+ const customTag = targetOrTag;
665
+ return ( target, key, desc ) => applyProfileToMethod( customTag, desc );
666
+
667
+ }
668
+
669
+ return defaultProfileDecorator( targetOrTag, propertyKey, descriptor );
670
+
671
+ }
672
+
673
+ /**
444
674
  * @param {Object} target
445
675
  * @param {string|symbol} propertyKey
446
676
  * @param {PropertyDescriptor} descriptor
447
677
  * @return {PropertyDescriptor}
448
678
  */
449
- function profile( target, propertyKey, descriptor ) {
679
+ function defaultProfileDecorator( target, propertyKey, descriptor ) {
450
680
 
451
681
  const className = target.constructor?.name ?? 'Unknown';
452
682
  return applyProfileToMethod( `${className}.${String( propertyKey )}`, descriptor );