@gnsx/three 0.184.19 → 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.19",
3
+ "version": "0.184.20",
4
4
  "description": "JavaScript 3D library",
5
5
  "type": "module",
6
6
  "main": "./build/three.cjs",
@@ -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() {
@@ -30,7 +38,7 @@ class ProfilerServiceClass {
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 = [];
@@ -56,6 +64,8 @@ class ProfilerServiceClass {
56
64
  this.traceStartTime = performance.now();
57
65
  this.begin = this._beginImpl.bind( this );
58
66
  this.end = this._endImpl.bind( this );
67
+ this.beginSpan = this._beginSpanImpl.bind( this );
68
+ this.endSpan = this._endSpanImpl.bind( this );
59
69
  this._exposeGlobal();
60
70
  console.log( `[ProfilerService] auto-started from env (profile: ${this._profile})` );
61
71
 
@@ -63,6 +73,8 @@ class ProfilerServiceClass {
63
73
 
64
74
  this.begin = NOOP;
65
75
  this.end = NOOP;
76
+ this.beginSpan = NOOP_BEGIN_SPAN;
77
+ this.endSpan = NOOP_END_SPAN;
66
78
 
67
79
  }
68
80
 
@@ -94,6 +106,8 @@ class ProfilerServiceClass {
94
106
  this._enabled = true;
95
107
  this.begin = this._beginImpl.bind( this );
96
108
  this.end = this._endImpl.bind( this );
109
+ this.beginSpan = this._beginSpanImpl.bind( this );
110
+ this.endSpan = this._endSpanImpl.bind( this );
97
111
  this._exposeGlobal();
98
112
  console.log( `[ProfilerService] enabled (profile: ${this._profile}) — call __gnsx_profiler.report() or downloadTrace() from the console` );
99
113
 
@@ -105,6 +119,8 @@ class ProfilerServiceClass {
105
119
  this._enabled = false;
106
120
  this.begin = NOOP;
107
121
  this.end = NOOP;
122
+ this.beginSpan = NOOP_BEGIN_SPAN;
123
+ this.endSpan = NOOP_END_SPAN;
108
124
  console.log( '[ProfilerService] disabled' );
109
125
 
110
126
  }
@@ -140,22 +156,8 @@ class ProfilerServiceClass {
140
156
 
141
157
  }
142
158
 
143
- const traced = this._profile === 'full' && this.traceEvents.length < MAX_TRACE_EVENTS;
144
- if ( traced ) {
145
-
146
- this.traceEvents.push( {
147
- name: label,
148
- ph: 'B',
149
- ts: this._getTraceTimestamp( startTime ),
150
- pid: 1,
151
- tid: 1,
152
- cat: 'gnsx',
153
- } );
154
-
155
- }
156
-
157
159
  performance.mark( startMark );
158
- stack.push( { startTime, startMark, traced } );
160
+ stack.push( { startTime, startMark } );
159
161
 
160
162
  // Push onto the global call stack for exclusive-time tracking.
161
163
  this.callStack.push( { label, childTime: 0 } );
@@ -173,13 +175,61 @@ class ProfilerServiceClass {
173
175
  if ( stack.length === 0 ) this.marks.delete( label );
174
176
 
175
177
  const now = performance.now();
176
- const { startTime, startMark, traced } = mark;
178
+ const { startTime, startMark } = mark;
177
179
  const duration = now - startTime;
178
180
  const endMark = `gnsx:${label}:end:${++ this._markId}`;
179
181
 
180
182
  performance.mark( endMark );
181
183
  performance.measure( `gnsx:${label}`, startMark, endMark );
182
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
+
183
233
  let buffer = this.buffers.get( label );
184
234
  if ( ! buffer ) {
185
235
 
@@ -191,7 +241,7 @@ class ProfilerServiceClass {
191
241
  }
192
242
 
193
243
  const cursor = this.cursors.get( label );
194
- buffer[ cursor ] = duration;
244
+ buffer[ cursor ] = durationMs;
195
245
  this.cursors.set( label, ( cursor + 1 ) % RING_SIZE );
196
246
  this.counts.set( label, Math.min( ( this.counts.get( label ) + 1 ), RING_SIZE ) );
197
247
 
@@ -203,7 +253,7 @@ class ProfilerServiceClass {
203
253
  if ( top !== undefined && top.label === label ) {
204
254
 
205
255
  this.callStack.pop();
206
- const selfTime = Math.max( 0, duration - top.childTime );
256
+ const selfTime = Math.max( 0, durationMs - top.childTime );
207
257
 
208
258
  let selfBuffer = this.selfBuffers.get( label );
209
259
  if ( ! selfBuffer ) {
@@ -222,7 +272,7 @@ class ProfilerServiceClass {
222
272
 
223
273
  // Propagate inclusive duration to the parent scope's child accumulator.
224
274
  const parent = this.callStack.length > 0 ? this.callStack[ this.callStack.length - 1 ] : undefined;
225
- if ( parent !== undefined ) parent.childTime += duration;
275
+ if ( parent !== undefined ) parent.childTime += durationMs;
226
276
 
227
277
  } else {
228
278
 
@@ -231,14 +281,16 @@ class ProfilerServiceClass {
231
281
 
232
282
  }
233
283
 
234
- if ( traced ) {
284
+ if ( traceTid ) {
235
285
 
286
+ const traceName = traceTid === TRACE_TID_ASYNC ? `${label} (promise)` : label;
236
287
  this.traceEvents.push( {
237
- name: label,
238
- ph: 'E',
239
- 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 ) ),
240
292
  pid: 1,
241
- tid: 1,
293
+ tid: traceTid,
242
294
  cat: 'gnsx',
243
295
  } );
244
296
 
@@ -349,13 +401,53 @@ class ProfilerServiceClass {
349
401
  */
350
402
  exportChromeTrace() {
351
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
+
352
448
  return {
353
449
  displayTimeUnit: 'ms',
354
- traceEvents: [
355
- { name: 'process_name', ph: 'M', pid: 1, args: { name: 'Genesys Profiler' } },
356
- { name: 'thread_name', ph: 'M', pid: 1, tid: 1, args: { name: 'Main Thread' } },
357
- ...this.traceEvents,
358
- ],
450
+ traceEvents: [ ...prefix, ...slices ],
359
451
  };
360
452
 
361
453
  }
@@ -460,22 +552,38 @@ class ProfilerServiceClass {
460
552
  * @property {number|undefined} selfFrameBudget Exclusive time as percentage of a 60 fps frame budget.
461
553
  */
462
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).
566
+ */
567
+
463
568
  /**
464
569
  * @typedef {Object} ChromeTraceEvent
465
570
  * @property {string} name
466
- * @property {'B'|'E'} ph
571
+ * @property {'X'} ph
467
572
  * @property {number} ts
573
+ * @property {number} dur
468
574
  * @property {1} pid
469
- * @property {1} tid
575
+ * @property {number} tid
470
576
  * @property {'gnsx'} cat
471
577
  */
472
578
 
473
579
  /**
474
580
  * @typedef {Object} ChromeTraceMetadataEvent
581
+ * @property {'__metadata'} cat
475
582
  * @property {'process_name'|'thread_name'} name
476
583
  * @property {'M'} ph
477
584
  * @property {1} pid
478
- * @property {1} [tid]
585
+ * @property {number} [tid]
586
+ * @property {0} ts
479
587
  * @property {{ name: string }} args
480
588
  */
481
589
 
@@ -491,28 +599,38 @@ class ProfilerServiceClass {
491
599
 
492
600
  const ProfilerService = new ProfilerServiceClass();
493
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
+
494
612
  function applyProfileToMethod( label, descriptor ) {
495
613
 
496
614
  const original = descriptor.value;
497
615
 
498
616
  function profiled( ...args ) {
499
617
 
500
- ProfilerService.begin( label );
618
+ const span = ProfilerService.beginSpan( label );
501
619
  try {
502
620
 
503
621
  const result = original.apply( this, args );
504
- if ( result instanceof Promise ) {
622
+ if ( isThenable( result ) ) {
505
623
 
506
- return result.finally( () => ProfilerService.end( label ) );
624
+ return Promise.resolve( result ).finally( () => ProfilerService.endSpan( span, { asyncTimeline: true } ) );
507
625
 
508
626
  }
509
627
 
510
- ProfilerService.end( label );
628
+ ProfilerService.endSpan( span );
511
629
  return result;
512
630
 
513
631
  } catch ( error ) {
514
632
 
515
- ProfilerService.end( label );
633
+ ProfilerService.endSpan( span );
516
634
  throw error;
517
635
 
518
636
  }
@@ -526,14 +644,39 @@ function applyProfileToMethod( label, descriptor ) {
526
644
 
527
645
  /**
528
646
  * Method decorator that profiles the decorated method.
529
- * The label is automatically set to `ClassName.methodName`.
647
+ * Default label: `ClassName.methodName`. Pass a custom tag via `@profile('My tag')`.
530
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
+ /**
531
674
  * @param {Object} target
532
675
  * @param {string|symbol} propertyKey
533
676
  * @param {PropertyDescriptor} descriptor
534
677
  * @return {PropertyDescriptor}
535
678
  */
536
- function profile( target, propertyKey, descriptor ) {
679
+ function defaultProfileDecorator( target, propertyKey, descriptor ) {
537
680
 
538
681
  const className = target.constructor?.name ?? 'Unknown';
539
682
  return applyProfileToMethod( `${className}.${String( propertyKey )}`, descriptor );