@gnsx/three 0.184.10 → 0.184.12

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.
@@ -7,9 +7,14 @@ import {
7
7
  } from 'three';
8
8
  import { NodeMaterial } from 'three/webgpu';
9
9
  import {
10
+ abs,
10
11
  cubeTexture,
11
12
  Fn,
12
- normalWorld,
13
+ max,
14
+ normalLocal,
15
+ positionLocal,
16
+ select,
17
+ vec3,
13
18
  vec4
14
19
  } from 'three/tsl';
15
20
 
@@ -17,7 +22,41 @@ function createNodeMaterial( texture ) {
17
22
 
18
23
  const fragmentNode = Fn( () => {
19
24
 
20
- return vec4( cubeTexture( texture, normalWorld ).rgb, 1.0 );
25
+ // Get local position and normal
26
+ const localPos = positionLocal;
27
+ const localNormal = normalLocal;
28
+
29
+ // Get absolute normal components to determine dominant axis
30
+ const absNormal = abs( localNormal );
31
+ const maxComp = max( max( absNormal.x, absNormal.y ), absNormal.z );
32
+
33
+ // Determine which face we're on and compute sample direction
34
+ const isX = maxComp.equal( absNormal.x );
35
+ const isY = maxComp.equal( absNormal.y ).and( isX.not() );
36
+
37
+ // Match LightProbeGrid / WebGL cubemap face conventions (see LightProbeGrid.js SH projection).
38
+ const xPositive = localNormal.x.greaterThan( 0.0 );
39
+ const xDir = select( xPositive,
40
+ vec3( 1.0, localPos.y, localPos.z.negate() ).normalize(),
41
+ vec3( - 1.0, localPos.y, localPos.z ).normalize()
42
+ );
43
+
44
+ const yPositive = localNormal.y.greaterThan( 0.0 );
45
+ const yDir = select( yPositive,
46
+ vec3( localPos.x, 1.0, localPos.z.negate() ).normalize(),
47
+ vec3( localPos.x, - 1.0, localPos.z ).normalize()
48
+ );
49
+
50
+ const zPositive = localNormal.z.greaterThan( 0.0 );
51
+ const zDir = select( zPositive,
52
+ vec3( localPos.x, localPos.y, 1.0 ).normalize(),
53
+ vec3( localPos.x, localPos.y, - 1.0 ).normalize()
54
+ );
55
+
56
+ // Select the appropriate direction based on which axis is dominant
57
+ const sampleDir = select( isX, xDir, select( isY, yDir, zDir ) );
58
+
59
+ return vec4( cubeTexture( texture, sampleDir ).rgb, 1.0 );
21
60
 
22
61
  } )();
23
62
 
@@ -40,11 +79,13 @@ function createShaderMaterial( texture ) {
40
79
 
41
80
  vertexShader: /* glsl */`
42
81
 
43
- varying vec3 vWorldNormal;
82
+ varying vec3 vLocalPosition;
83
+ varying vec3 vNormal;
44
84
 
45
85
  void main() {
46
86
 
47
- vWorldNormal = normalize( mat3( modelMatrix ) * normal );
87
+ vLocalPosition = position;
88
+ vNormal = normal;
48
89
  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
49
90
 
50
91
  }
@@ -57,12 +98,56 @@ function createShaderMaterial( texture ) {
57
98
 
58
99
  uniform samplerCube cubeTexture;
59
100
 
60
- varying vec3 vWorldNormal;
101
+ varying vec3 vLocalPosition;
102
+ varying vec3 vNormal;
61
103
 
62
104
  void main() {
105
+ // Get the dominant axis from the normal to determine which face we're on
106
+ vec3 absNormal = abs( vNormal );
107
+ float maxComp = max( max( absNormal.x, absNormal.y ), absNormal.z );
108
+
109
+ vec3 sampleDir;
110
+
111
+ // Match LightProbeGrid / WebGL cubemap face conventions (see LightProbeGrid.js SH projection).
112
+ if ( maxComp == absNormal.x ) {
113
+
114
+ if ( vNormal.x > 0.0 ) {
115
+
116
+ sampleDir = normalize( vec3( 1.0, vLocalPosition.y, -vLocalPosition.z ) );
117
+
118
+ } else {
119
+
120
+ sampleDir = normalize( vec3( -1.0, vLocalPosition.y, vLocalPosition.z ) );
121
+
122
+ }
123
+
124
+ } else if ( maxComp == absNormal.y ) {
125
+
126
+ if ( vNormal.y > 0.0 ) {
127
+
128
+ sampleDir = normalize( vec3( vLocalPosition.x, 1.0, -vLocalPosition.z ) );
129
+
130
+ } else {
131
+
132
+ sampleDir = normalize( vec3( vLocalPosition.x, -1.0, vLocalPosition.z ) );
133
+
134
+ }
135
+
136
+ } else {
137
+
138
+ if ( vNormal.z > 0.0 ) {
139
+
140
+ sampleDir = normalize( vec3( vLocalPosition.x, vLocalPosition.y, 1.0 ) );
141
+
142
+ } else {
143
+
144
+ sampleDir = normalize( vec3( vLocalPosition.x, vLocalPosition.y, -1.0 ) );
145
+
146
+ }
147
+
148
+ }
63
149
 
64
- vec3 direction = normalize( vWorldNormal );
65
- vec3 color = textureCube( cubeTexture, direction ).rgb;
150
+ vec3 color = textureCube( cubeTexture, sampleDir ).rgb;
66
151
 
67
152
  gl_FragColor = vec4( color, 1.0 );
68
153
 
@@ -238,7 +238,11 @@ class LightProbeGrid extends Object3D {
238
238
  */
239
239
  getProbePosition( ix, iy, iz, target ) {
240
240
 
241
- const pos = this.position;
241
+ // const pos = this.position;
242
+ // WITH_GENESYS
243
+ const pos = _position;
244
+ this.getWorldPosition( pos );
245
+ // !WITH_GENESYS
242
246
  const res = this.resolution;
243
247
  const w = this.width, h = this.height, d = this.depth;
244
248
 
@@ -257,8 +261,15 @@ class LightProbeGrid extends Object3D {
257
261
  */
258
262
  updateBoundingBox() {
259
263
 
264
+ // WITH_GENESYS
265
+ this.getWorldPosition( _position );
266
+ _size.set( this.width, this.height, this.depth );
267
+ this.boundingBox.setFromCenterAndSize( _position, _size );
268
+ // !WITH_GENESYS
269
+ /*
260
270
  _size.set( this.width, this.height, this.depth );
261
271
  this.boundingBox.setFromCenterAndSize( this.position, _size );
272
+ */
262
273
 
263
274
  }
264
275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gnsx/three",
3
- "version": "0.184.10",
3
+ "version": "0.184.12",
4
4
  "description": "JavaScript 3D library",
5
5
  "type": "module",
6
6
  "main": "./build/three.cjs",
package/src/Three.js CHANGED
@@ -8,3 +8,6 @@ export { UniformsUtils } from './renderers/shaders/UniformsUtils.js';
8
8
  export { ShaderChunk } from './renderers/shaders/ShaderChunk.js';
9
9
  export { PMREMGenerator } from './extras/PMREMGenerator.js';
10
10
  export { WebGLUtils } from './renderers/webgl/WebGLUtils.js';
11
+ // WITH_GENESYS
12
+ export { ProfilerService, profile, profileClass } from './profiler/ProfilerService.js';
13
+ // !WITH_GENESYS
@@ -0,0 +1,431 @@
1
+ // WITH_GENESYS
2
+ /**
3
+ * ProfilerService — per-label CPU timing with ring-buffer aggregation and DevTools integration.
4
+ *
5
+ * Activate via environment variable:
6
+ * PROFILING=1 stats + trace (full profile)
7
+ * PROFILING=stats stats only, no trace accumulation (lower overhead)
8
+ *
9
+ * Quick start (browser console):
10
+ * __gnsx_profiler.enable()
11
+ * // play for a few seconds
12
+ * __gnsx_profiler.report() // sorted console.table (aggregated stats)
13
+ * __gnsx_profiler.downloadTrace() // download gnsx-trace.json for Speedscope / Perfetto
14
+ * __gnsx_profiler.reset() // clear samples and trace
15
+ * __gnsx_profiler.disable()
16
+ */
17
+
18
+ const RING_SIZE = 120;
19
+ const FRAME_BUDGET_MS = 1000 / 60;
20
+ /** Max events in the trace log (~50s at 60fps with 8 labels). */
21
+ const MAX_TRACE_EVENTS = 50_000;
22
+ const NOOP = () => {};
23
+
24
+ function _readProfilingEnv() {
25
+
26
+ const raw =
27
+ ( typeof process !== 'undefined' ? process.env?.[ 'PROFILING' ] : undefined ) ??
28
+ ( typeof globalThis !== 'undefined' ? globalThis[ '__gnsx_env__' ]?.[ 'PROFILING' ] : undefined ) ??
29
+ ( typeof globalThis !== 'undefined' ? globalThis[ '__GNSX_PROFILING__' ] : undefined );
30
+
31
+ if ( ! raw || raw === '0' || raw === 'false' ) return false;
32
+ if ( raw === 'stats' ) return 'stats';
33
+ return 'full';
34
+
35
+ }
36
+
37
+ class ProfilerServiceClass {
38
+
39
+ constructor() {
40
+
41
+ const envProfile = _readProfilingEnv();
42
+ this._profile = envProfile !== false ? envProfile : 'full';
43
+ this._enabled = envProfile !== false;
44
+
45
+ /** @type {Map<string, Float64Array>} */
46
+ this.buffers = new Map();
47
+ /** @type {Map<string, number>} */
48
+ this.cursors = new Map();
49
+ /** @type {Map<string, number>} */
50
+ this.counts = new Map();
51
+ /** @type {Map<string, number>} */
52
+ this.marks = new Map();
53
+ /** @type {Array<{ name: string, ph: 'X', ts: number, dur: number, pid: 1, tid: 1, cat: 'gnsx' }>} */
54
+ this.traceEvents = [];
55
+ this.traceStartTime = 0;
56
+
57
+ if ( this._enabled ) {
58
+
59
+ this.traceStartTime = performance.now();
60
+ this.begin = this._beginImpl.bind( this );
61
+ this.end = this._endImpl.bind( this );
62
+ this._exposeGlobal();
63
+ console.log( `[ProfilerService] auto-started from env (profile: ${this._profile})` );
64
+
65
+ } else {
66
+
67
+ this.begin = NOOP;
68
+ this.end = NOOP;
69
+
70
+ }
71
+
72
+ }
73
+
74
+ /**
75
+ * Set the active profile. Must be called before enable() to take effect on the current session.
76
+ *
77
+ * @param {'full'|'stats'} profile
78
+ */
79
+ setProfile( profile ) {
80
+
81
+ this._profile = profile;
82
+
83
+ }
84
+
85
+ /**
86
+ * @return {'full'|'stats'}
87
+ */
88
+ getProfile() {
89
+
90
+ return this._profile;
91
+
92
+ }
93
+
94
+ enable() {
95
+
96
+ this._clearState();
97
+ this._enabled = true;
98
+ this.begin = this._beginImpl.bind( this );
99
+ this.end = this._endImpl.bind( this );
100
+ this._exposeGlobal();
101
+ console.log( `[ProfilerService] enabled (profile: ${this._profile}) — call __gnsx_profiler.report() or downloadTrace() from the console` );
102
+
103
+ }
104
+
105
+ disable() {
106
+
107
+ this._clearState();
108
+ this._enabled = false;
109
+ this.begin = NOOP;
110
+ this.end = NOOP;
111
+ console.log( '[ProfilerService] disabled' );
112
+
113
+ }
114
+
115
+ isEnabled() {
116
+
117
+ return this._enabled;
118
+
119
+ }
120
+
121
+ _exposeGlobal() {
122
+
123
+ if ( typeof window !== 'undefined' ) {
124
+
125
+ window.__gnsx_profiler = this;
126
+
127
+ }
128
+
129
+ }
130
+
131
+ /**
132
+ * @param {string} label
133
+ */
134
+ _beginImpl( label ) {
135
+
136
+ performance.mark( `gnsx:${label}:start` );
137
+ this.marks.set( label, performance.now() );
138
+
139
+ }
140
+
141
+ /**
142
+ * @param {string} label
143
+ */
144
+ _endImpl( label ) {
145
+
146
+ const startTime = this.marks.get( label );
147
+ if ( startTime === undefined ) return;
148
+ this.marks.delete( label );
149
+
150
+ const now = performance.now();
151
+ const duration = now - startTime;
152
+
153
+ performance.mark( `gnsx:${label}:end` );
154
+ performance.measure( `gnsx:${label}`, `gnsx:${label}:start`, `gnsx:${label}:end` );
155
+
156
+ let buffer = this.buffers.get( label );
157
+ if ( ! buffer ) {
158
+
159
+ buffer = new Float64Array( RING_SIZE );
160
+ this.buffers.set( label, buffer );
161
+ this.cursors.set( label, 0 );
162
+ this.counts.set( label, 0 );
163
+
164
+ }
165
+
166
+ const cursor = this.cursors.get( label );
167
+ buffer[ cursor ] = duration;
168
+ this.cursors.set( label, ( cursor + 1 ) % RING_SIZE );
169
+ this.counts.set( label, Math.min( ( this.counts.get( label ) + 1 ), RING_SIZE ) );
170
+
171
+ if ( this._profile === 'full' && this.traceEvents.length < MAX_TRACE_EVENTS ) {
172
+
173
+ this.traceEvents.push( {
174
+ name: label,
175
+ ph: 'X',
176
+ ts: Math.round( ( startTime - this.traceStartTime ) * 1000 ),
177
+ dur: Math.max( 1, Math.round( duration * 1000 ) ),
178
+ pid: 1,
179
+ tid: 1,
180
+ cat: 'gnsx',
181
+ } );
182
+
183
+ }
184
+
185
+ }
186
+
187
+ /**
188
+ * @param {string} label
189
+ * @return {number[]}
190
+ */
191
+ getValidSamples( label ) {
192
+
193
+ const buffer = this.buffers.get( label );
194
+ const count = this.counts.get( label ) ?? 0;
195
+ if ( ! buffer || count === 0 ) return [];
196
+ return count < RING_SIZE
197
+ ? Array.from( buffer.subarray( 0, count ) )
198
+ : Array.from( buffer );
199
+
200
+ }
201
+
202
+ /**
203
+ * @param {string} label
204
+ * @return {import('./ProfilerService.js').ProfilerStats|null}
205
+ */
206
+ getStats( label ) {
207
+
208
+ const samples = this.getValidSamples( label );
209
+ if ( samples.length === 0 ) return null;
210
+
211
+ const sorted = [ ...samples ].sort( ( a, b ) => a - b );
212
+ const avg = sorted.reduce( ( a, b ) => a + b, 0 ) / sorted.length;
213
+
214
+ return {
215
+ label,
216
+ samples: samples.length,
217
+ avg,
218
+ min: sorted[ 0 ],
219
+ max: sorted[ sorted.length - 1 ],
220
+ p95: sorted[ Math.floor( sorted.length * 0.95 ) ],
221
+ frameBudget: ( avg / FRAME_BUDGET_MS ) * 100,
222
+ };
223
+
224
+ }
225
+
226
+ /**
227
+ * @return {import('./ProfilerService.js').ProfilerStats[]}
228
+ */
229
+ getAllStats() {
230
+
231
+ return [ ...this.buffers.keys() ]
232
+ .map( label => this.getStats( label ) )
233
+ .filter( s => s !== null );
234
+
235
+ }
236
+
237
+ report() {
238
+
239
+ const stats = this.getAllStats();
240
+ if ( stats.length === 0 ) {
241
+
242
+ console.log( '[ProfilerService] No data. Enable profiling first and wait a few frames.' );
243
+ return;
244
+
245
+ }
246
+
247
+ stats.sort( ( a, b ) => b.avg - a.avg );
248
+ console.table(
249
+ stats.map( s => ( {
250
+ label: s.label,
251
+ 'avg ms': s.avg.toFixed( 3 ),
252
+ 'min ms': s.min.toFixed( 3 ),
253
+ 'max ms': s.max.toFixed( 3 ),
254
+ 'p95 ms': s.p95.toFixed( 3 ),
255
+ 'budget %': s.frameBudget.toFixed( 1 ) + '%',
256
+ samples: s.samples,
257
+ } ) )
258
+ );
259
+
260
+ }
261
+
262
+ /**
263
+ * @return {import('./ProfilerService.js').ChromeTrace}
264
+ */
265
+ exportChromeTrace() {
266
+
267
+ return { traceEvents: [ ...this.traceEvents ] };
268
+
269
+ }
270
+
271
+ /**
272
+ * @param {string} [filename='gnsx-trace.json']
273
+ */
274
+ downloadTrace( filename = 'gnsx-trace.json' ) {
275
+
276
+ if ( typeof document === 'undefined' ) {
277
+
278
+ console.log( '[ProfilerService] downloadTrace() only works in the browser. Use exportChromeTrace() in Node.js.' );
279
+ return;
280
+
281
+ }
282
+
283
+ if ( this._profile !== 'full' ) {
284
+
285
+ console.log( `[ProfilerService] No trace — current profile is '${this._profile}'. Use setProfile('full') before enabling.` );
286
+ return;
287
+
288
+ }
289
+
290
+ if ( this.traceEvents.length === 0 ) {
291
+
292
+ console.log( '[ProfilerService] No trace events. Enable the profiler and play for a few seconds first.' );
293
+ return;
294
+
295
+ }
296
+
297
+ const json = JSON.stringify( this.exportChromeTrace() );
298
+ const blob = new Blob( [ json ], { type: 'application/json' } );
299
+ const url = URL.createObjectURL( blob );
300
+ const a = document.createElement( 'a' );
301
+ a.href = url;
302
+ a.download = filename;
303
+ a.click();
304
+ URL.revokeObjectURL( url );
305
+ console.log( `[ProfilerService] Trace downloaded: ${filename} (${this.traceEvents.length} events)` );
306
+
307
+ }
308
+
309
+ /**
310
+ * @return {import('./ProfilerService.js').ProfilerStats[]}
311
+ */
312
+ exportJSON() {
313
+
314
+ return this.getAllStats();
315
+
316
+ }
317
+
318
+ _clearState() {
319
+
320
+ this.buffers.clear();
321
+ this.cursors.clear();
322
+ this.counts.clear();
323
+ this.marks.clear();
324
+ this.traceEvents = [];
325
+ this.traceStartTime = performance.now();
326
+
327
+ }
328
+
329
+ reset() {
330
+
331
+ this._clearState();
332
+ console.log( '[ProfilerService] data reset' );
333
+
334
+ }
335
+
336
+ }
337
+
338
+ /**
339
+ * @typedef {Object} ProfilerStats
340
+ * @property {string} label
341
+ * @property {number} samples
342
+ * @property {number} avg
343
+ * @property {number} min
344
+ * @property {number} max
345
+ * @property {number} p95
346
+ * @property {number} frameBudget Percentage of a 60 fps frame budget (16.67 ms)
347
+ */
348
+
349
+ /**
350
+ * @typedef {Object} ChromeTrace
351
+ * @property {Array<{ name: string, ph: 'X', ts: number, dur: number, pid: 1, tid: 1, cat: 'gnsx' }>} traceEvents
352
+ * @property {{ 'clock-offset-since-epoch-ns'?: number }|undefined} [metadata]
353
+ */
354
+
355
+ /**
356
+ * @typedef {'full'|'stats'} ProfilingProfile
357
+ */
358
+
359
+ const ProfilerService = new ProfilerServiceClass();
360
+
361
+ function applyProfileToMethod( label, descriptor ) {
362
+
363
+ if ( _readProfilingEnv() === false ) return descriptor;
364
+
365
+ const original = descriptor.value;
366
+
367
+ function profiled( ...args ) {
368
+
369
+ ProfilerService.begin( label );
370
+ const result = original.apply( this, args );
371
+ if ( result instanceof Promise ) {
372
+
373
+ return result.finally( () => ProfilerService.end( label ) );
374
+
375
+ }
376
+
377
+ ProfilerService.end( label );
378
+ return result;
379
+
380
+ }
381
+
382
+ descriptor.value = profiled;
383
+ return descriptor;
384
+
385
+ }
386
+
387
+ /**
388
+ * Method decorator that profiles the decorated method.
389
+ * The label is automatically set to `ClassName.methodName`.
390
+ *
391
+ * @param {Object} target
392
+ * @param {string|symbol} propertyKey
393
+ * @param {PropertyDescriptor} descriptor
394
+ * @return {PropertyDescriptor}
395
+ */
396
+ function profile( target, propertyKey, descriptor ) {
397
+
398
+ const className = target.constructor?.name ?? 'Unknown';
399
+ return applyProfileToMethod( `${className}.${String( propertyKey )}`, descriptor );
400
+
401
+ }
402
+
403
+ /**
404
+ * Class decorator that profiles every method of the decorated class.
405
+ *
406
+ * @template T
407
+ * @param {T} constructor
408
+ * @return {T}
409
+ */
410
+ function profileClass( constructor ) {
411
+
412
+ const proto = constructor.prototype;
413
+ for ( const key of Object.getOwnPropertyNames( proto ) ) {
414
+
415
+ if ( key === 'constructor' ) continue;
416
+ const descriptor = Object.getOwnPropertyDescriptor( proto, key );
417
+ if ( ! descriptor || typeof descriptor.value !== 'function' ) continue;
418
+ Object.defineProperty(
419
+ proto,
420
+ key,
421
+ applyProfileToMethod( `${constructor.name}.${key}`, { ...descriptor } )
422
+ );
423
+
424
+ }
425
+
426
+ return constructor;
427
+
428
+ }
429
+
430
+ export { ProfilerService, profile, profileClass };
431
+ // !WITH_GENESYS