zincjs 2.4.1-beta.2 → 2.4.1-beta.4

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.
@@ -2,6 +2,8 @@ import * as THREE from 'three/webgpu';
2
2
  import { Glyph } from './glyph';
3
3
  import { ZincObject } from './zincObject';
4
4
  import { JSONLoader } from '../loaders/JSONLoader';
5
+ import { createGlyphTransformCompute, dispatchAndReadbackGlyphTransform, readbackGlyphTransform } from '../tsl/glyphTransform';
6
+ import { createGlyphInstancedMaterial } from '../tsl/glyphRenderMaterial';
5
7
 
6
8
  /**
7
9
  * This is a container of {@link Glyph} and their graphical properties
@@ -20,6 +22,8 @@ const Glyphset = function () {
20
22
  let axis2s = undefined;
21
23
  let axis3s = undefined;
22
24
  let positions = undefined;
25
+ //Glyph geometry loaded after dispose is discarded
26
+ let disposed = false;
23
27
  let scales = undefined;
24
28
  let colors = undefined;
25
29
  let labels = undefined;
@@ -49,10 +53,92 @@ const Glyphset = function () {
49
53
  const _current_colors = [];
50
54
  const _glyph_axis_array = [];
51
55
  this.globalScale = 1;
56
+ let glyphCompute = undefined;
57
+ let glyphComputePending = false;
58
+ // True once a full resync (dispatchGlyphCompute) has completed and nothing
59
+ // has invalidated it since; false during active playback and while a resync
60
+ // is in flight.
61
+ let boundsAreExact = true;
62
+ let renderBuffersInitialized = false;
63
+ let wasAnimating = false;
64
+ // Conservative bounding box unioned across every keyframe's exact
65
+ let allFramesBoundingBox = undefined;
66
+ // Debounces the accurate GPU->CPU readback (dispatchGlyphCompute) behind
67
+ // setMorphTime() - e.g. a downstream slider firing many calls per second
68
+ // while being dragged. The fast, readback-free GPU-only update still
69
+ // happens on every call (see updateMorphGlyphsets), so visuals stay live;
70
+ // Only the costlier readback (bounding box/raycast/export accuracy) waits
71
+ // for ACCURATE_RESYNC_DEBOUNCE_MS of quiet.
72
+ let accurateResyncTimer = undefined;
73
+ const ACCURATE_RESYNC_DEBOUNCE_MS = 500;
52
74
  for (let i = 0; i < 8; i++) {
53
75
  _points[i] = new THREE.Vector3();
54
76
  }
55
77
 
78
+ /**
79
+ * Writes a resolve_glyph_axes() result tuple [point, axis1, axis2, axis3]
80
+ * into a THREE.Matrix4's .elements, matching the column-major
81
+ * axis1/axis2/axis3/point layout instanceMatrix expects.
82
+ */
83
+ const writeTransformMatrix = (matrix, arrayTuple) => {
84
+ matrix.elements[0] = arrayTuple[1][0];
85
+ matrix.elements[1] = arrayTuple[1][1];
86
+ matrix.elements[2] = arrayTuple[1][2];
87
+ matrix.elements[3] = 0.0;
88
+ matrix.elements[4] = arrayTuple[2][0];
89
+ matrix.elements[5] = arrayTuple[2][1];
90
+ matrix.elements[6] = arrayTuple[2][2];
91
+ matrix.elements[7] = 0.0;
92
+ matrix.elements[8] = arrayTuple[3][0];
93
+ matrix.elements[9] = arrayTuple[3][1];
94
+ matrix.elements[10] = arrayTuple[3][2];
95
+ matrix.elements[11] = 0.0;
96
+ matrix.elements[12] = arrayTuple[0][0];
97
+ matrix.elements[13] = arrayTuple[0][1];
98
+ matrix.elements[14] = arrayTuple[0][2];
99
+ matrix.elements[15] = 1.0;
100
+ }
101
+
102
+ /**
103
+ * Flattens a { "0": [x,y,z,...], "1": [...], ... } per-keyframe object
104
+ * (as used for axis1/axis2/axis3/positions/scale) into one contiguous
105
+ * [time][record][xyz] buffer for the GPU compute path.
106
+ */
107
+ const flattenVec3Frames = (framesObj, steps, baseCount) => {
108
+ const out = new Float32Array(steps * baseCount * 3);
109
+ for (let t = 0; t < steps; t++) {
110
+ const frame = framesObj[t.toString()];
111
+ if (frame) {
112
+ out.set(frame, t * baseCount * 3);
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * Same as flattenVec3Frames but for the colours object, whose per-frame
120
+ * values are one packed hex int per glyph rather than 3 raw components -
121
+ * decode each into RGB floats up front so the compute shader only ever
122
+ * deals with plain vec3s.
123
+ */
124
+ const flattenColorFrames = (framesObj, steps, baseCount) => {
125
+ const out = new Float32Array(steps * baseCount * 3);
126
+ const tmpColor = new THREE.Color();
127
+ for (let t = 0; t < steps; t++) {
128
+ const frame = framesObj[t.toString()];
129
+ if (frame) {
130
+ for (let i = 0; i < baseCount; i++) {
131
+ tmpColor.setHex(frame[i]);
132
+ const o = (t * baseCount + i) * 3;
133
+ out[o] = tmpColor.r;
134
+ out[o + 1] = tmpColor.g;
135
+ out[o + 2] = tmpColor.b;
136
+ }
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+
56
142
  /**
57
143
  * Copy glyphset data into this glyphset then load the glyph's geoemtry
58
144
  * with the provided glyphURL. FinishCallback will be called once
@@ -65,6 +151,7 @@ const Glyphset = function () {
65
151
  * geometry is loaded.
66
152
  */
67
153
  this.load = (glyphsetData, glyphURL, finishCallback, isInline, displayLabels) => {
154
+ disposed = false;
68
155
  axis1s = glyphsetData.axis1;
69
156
  axis2s = glyphsetData.axis2;
70
157
  axis3s = glyphsetData.axis3;
@@ -84,6 +171,30 @@ const Glyphset = function () {
84
171
  baseSize = glyphsetData.metadata.base_size;
85
172
  offset = glyphsetData.metadata.offset;
86
173
  scaleFactors = glyphsetData.metadata.scale_factors;
174
+ glyphCompute = undefined;
175
+ if (morphVertices && numberOfTimeSteps > 1 && positions && positions["0"]) {
176
+ const baseCount = positions["0"].length / 3;
177
+ try {
178
+ glyphCompute = createGlyphTransformCompute({
179
+ baseCount,
180
+ outputCount: numberOfVertices,
181
+ repeat_mode,
182
+ positionsData: flattenVec3Frames(positions, numberOfTimeSteps, baseCount),
183
+ axis1Data: flattenVec3Frames(axis1s, numberOfTimeSteps, baseCount),
184
+ axis2Data: flattenVec3Frames(axis2s, numberOfTimeSteps, baseCount),
185
+ axis3Data: flattenVec3Frames(axis3s, numberOfTimeSteps, baseCount),
186
+ scaleData: flattenVec3Frames(scales, numberOfTimeSteps, baseCount),
187
+ colorData: (morphColours && colors) ? flattenColorFrames(colors, numberOfTimeSteps, baseCount) : undefined,
188
+ baseSize,
189
+ offset,
190
+ scaleFactors,
191
+ globalScale: this.globalScale,
192
+ });
193
+ } catch (err) {
194
+ console.error("Failed to set up GPU glyph transform compute, falling back to CPU animation.", err);
195
+ glyphCompute = undefined;
196
+ }
197
+ }
87
198
  const loader = new JSONLoader();
88
199
  this.geometry = new THREE.BufferGeometry();
89
200
  const instancedMesh = new THREE.InstancedMesh(this.geometry, undefined, numberOfVertices);
@@ -180,7 +291,7 @@ const Glyphset = function () {
180
291
  + offset[1] * axis_scale[1] * axis2[j]
181
292
  + offset[2] * axis_scale[2] * axis3[j];
182
293
  }
183
- const number_of_glyphs = (glyph_repeat_mode == "AXES_2D") ? 2 : 3;
294
+ const number_of_glyphs = (repeat_mode == "AXES_2D") ? 2 : 3;
184
295
  for (let k = 0; k < number_of_glyphs; k++) {
185
296
  let use_axis1, use_axis2;
186
297
  const use_scale = scale[k];
@@ -193,7 +304,7 @@ const Glyphset = function () {
193
304
  }
194
305
  else if (k == 1) {
195
306
  use_axis1 = axis2;
196
- use_axis2 = (glyph_repeat_mode == "AXES_2D") ? axis1 : axis3;
307
+ use_axis2 = (repeat_mode == "AXES_2D") ? axis1 : axis3;
197
308
  }
198
309
  else // if (k == 2)
199
310
  {
@@ -268,22 +379,7 @@ const Glyphset = function () {
268
379
  current_axis3, current_scale, _glyph_axis_array);
269
380
  if (arrays.length == numberOfGlyphs) {
270
381
  for (let j = 0; j < numberOfGlyphs; j++) {
271
- _transformMatrix.elements[0] = arrays[j][1][0];
272
- _transformMatrix.elements[1] = arrays[j][1][1];
273
- _transformMatrix.elements[2] = arrays[j][1][2];
274
- _transformMatrix.elements[3] = 0.0;
275
- _transformMatrix.elements[4] = arrays[j][2][0];
276
- _transformMatrix.elements[5] = arrays[j][2][1];
277
- _transformMatrix.elements[6] = arrays[j][2][2];
278
- _transformMatrix.elements[7] = 0.0;
279
- _transformMatrix.elements[8] = arrays[j][3][0];
280
- _transformMatrix.elements[9] = arrays[j][3][1];
281
- _transformMatrix.elements[10] = arrays[j][3][2];
282
- _transformMatrix.elements[11] = 0.0;
283
- _transformMatrix.elements[12] = arrays[j][0][0];
284
- _transformMatrix.elements[13] = arrays[j][0][1];
285
- _transformMatrix.elements[14] = arrays[j][0][2];
286
- _transformMatrix.elements[15] = 1.0;
382
+ writeTransformMatrix(_transformMatrix, arrays[j]);
287
383
  this.morph.setMatrixAt(current_glyph_index, _transformMatrix);
288
384
  const glyph = glyphList[current_glyph_index];
289
385
  if (glyph) {
@@ -301,8 +397,19 @@ const Glyphset = function () {
301
397
 
302
398
  /**
303
399
  * Update colour for each of the glyph in this glyphset.
400
+ *
401
+ * @param {Array} current_colors - one hex value per raw (pre-repeat_mode-
402
+ * expansion) glyph record.
403
+ * @param {Boolean} labelsOnly - when true, only glyph labels are
404
+ * recoloured - instanceColor is left untouched (not even created). Used
405
+ * for the initial load-time colour of GPU-compute-driven glyphsets (see
406
+ * createGlyphs()): instanceColor must stay uncreated there (see
407
+ * applyGlyphComputeResult's comment on the NodeMaterial double-multiply
408
+ * this avoids), but labels aren't part of the compute-buffer render path
409
+ * at all and would otherwise show no colour until the first accurate
410
+ * resync.
304
411
  */
305
- const updateGlyphsetHexColors = current_colors => {
412
+ const updateGlyphsetHexColors = (current_colors, labelsOnly) => {
306
413
  let numberOfGlyphs = 1;
307
414
  if (repeat_mode == "AXES_2D" || repeat_mode == "MIRROR")
308
415
  numberOfGlyphs = 2;
@@ -314,22 +421,213 @@ const Glyphset = function () {
314
421
  const hex_values = current_colors[i];
315
422
  for (let j = 0; j < numberOfGlyphs; j++) {
316
423
  _bot_colour.setHex(hex_values)
317
- this.morph.setColorAt(current_glyph_index, _bot_colour);
424
+ if (!labelsOnly) {
425
+ this.morph.setColorAt(current_glyph_index, _bot_colour);
426
+ }
318
427
  const glyph = glyphList[current_glyph_index];
319
428
  if (glyph)
320
429
  glyph.setColour(_bot_colour);
321
430
  current_glyph_index++;
322
431
  }
323
432
  }
324
- this.morph.instanceColor.needsUpdate = true;
433
+ if (!labelsOnly) {
434
+ this.morph.instanceColor.needsUpdate = true;
435
+ }
436
+ };
437
+
438
+ /**
439
+ * Writes back a GPU glyph-transform compute readback (see
440
+ * dispatchGlyphCompute) into instanceMatrix/instanceColor - i.e. exactly
441
+ * the data updateGlyphsetTransformation()/updateGlyphsetHexColors() write
442
+ * on the CPU path, so bounding box/raycast/getClosestVertex keep working
443
+ * completely unchanged downstream.
444
+ */
445
+ const applyGlyphComputeResult = (result) => {
446
+ const matrixArray = this.morph.instanceMatrix.array;
447
+ const updateLabels = this.canShowLabel();
448
+ for (let i = 0; i < numberOfVertices; i++) {
449
+ const o4 = i * 4;
450
+ const o16 = i * 16;
451
+ matrixArray[o16] = result.axis1[o4];
452
+ matrixArray[o16 + 1] = result.axis1[o4 + 1];
453
+ matrixArray[o16 + 2] = result.axis1[o4 + 2];
454
+ matrixArray[o16 + 3] = 0;
455
+ matrixArray[o16 + 4] = result.axis2[o4];
456
+ matrixArray[o16 + 5] = result.axis2[o4 + 1];
457
+ matrixArray[o16 + 6] = result.axis2[o4 + 2];
458
+ matrixArray[o16 + 7] = 0;
459
+ matrixArray[o16 + 8] = result.axis3[o4];
460
+ matrixArray[o16 + 9] = result.axis3[o4 + 1];
461
+ matrixArray[o16 + 10] = result.axis3[o4 + 2];
462
+ matrixArray[o16 + 11] = 0;
463
+ matrixArray[o16 + 12] = result.position[o4];
464
+ matrixArray[o16 + 13] = result.position[o4 + 1];
465
+ matrixArray[o16 + 14] = result.position[o4 + 2];
466
+ matrixArray[o16 + 15] = 1;
467
+ if (updateLabels) {
468
+ const glyph = glyphList[i];
469
+ if (glyph) {
470
+ glyph.setTransformation(
471
+ [result.position[o4], result.position[o4 + 1], result.position[o4 + 2]],
472
+ [result.axis1[o4], result.axis1[o4 + 1], result.axis1[o4 + 2]],
473
+ [result.axis2[o4], result.axis2[o4 + 1], result.axis2[o4 + 2]],
474
+ [result.axis3[o4], result.axis3[o4 + 1], result.axis3[o4 + 2]],
475
+ );
476
+ }
477
+ }
478
+ }
479
+ this.morph.instanceMatrix.needsUpdate = true;
480
+ this.boundingBoxUpdateRequired = true;
481
+ this.morph.computeBoundingSphere();
482
+
483
+ if (result.color) {
484
+ const colorArray = this.morph.instanceColor ? this.morph.instanceColor.array : undefined;
485
+ for (let i = 0; i < numberOfVertices; i++) {
486
+ const o4 = i * 4;
487
+ if (colorArray) {
488
+ const o3 = i * 3;
489
+ colorArray[o3] = result.color[o4];
490
+ colorArray[o3 + 1] = result.color[o4 + 1];
491
+ colorArray[o3 + 2] = result.color[o4 + 2];
492
+ }
493
+ if (updateLabels) {
494
+ const glyph = glyphList[i];
495
+ if (glyph) {
496
+ _bot_colour.setRGB(result.color[o4], result.color[o4 + 1], result.color[o4 + 2]);
497
+ glyph.setColour(_bot_colour);
498
+ }
499
+ }
500
+ }
501
+ if (colorArray) {
502
+ this.morph.instanceColor.needsUpdate = true;
503
+ }
504
+ }
505
+ };
506
+
507
+ /**
508
+ * Kicks off (at most one in flight at a time) an async GPU compute +
509
+ * readback for the current bottom/top frame + proportion. If a previous
510
+ * dispatch is still resolving, this frame's update is skipped rather than
511
+ * queued - the next call to updateMorphGlyphsets() will dispatch with
512
+ * whatever bottom/top/proportion is current *then*, so playback can't
513
+ * fall further and further behind, at the cost of the displayed state
514
+ * lagging the true target time by about one frame while animating.
515
+ */
516
+ const dispatchGlyphCompute = (bottom_frame, top_frame, proportion) => {
517
+ if (glyphComputePending) return;
518
+ const renderer = this.region?.getScene?.()?.getRenderer?.();
519
+ if (!renderer) return;
520
+ glyphCompute.uniforms.bottomFrame.value = bottom_frame;
521
+ glyphCompute.uniforms.topFrame.value = top_frame;
522
+ glyphCompute.uniforms.proportion.value = proportion;
523
+ glyphCompute.uniforms.globalScale.value = this.globalScale;
524
+ glyphComputePending = true;
525
+ boundsAreExact = false;
526
+ dispatchAndReadbackGlyphTransform(renderer, glyphCompute).then((result) => {
527
+ glyphComputePending = false;
528
+ applyGlyphComputeResult(result);
529
+ boundsAreExact = true;
530
+ // Restore the stock InstancedMesh.raycast (instanceMatrix is now
531
+ // exact again) by dropping the no-op instance override, if present.
532
+ delete this.morph.raycast;
533
+ }).catch((err) => {
534
+ glyphComputePending = false;
535
+ console.error("Glyph transform compute readback failed.", err);
536
+ });
537
+ };
538
+
539
+ /**
540
+ * The fast per-frame path used while actively playing: only dispatches
541
+ * the compute pass (updating the GPU buffers the render material reads
542
+ * directly - see ../tsl/glyphRenderMaterial.js) with no CPU readback at
543
+ * all, bouding box will not be updated.
544
+ */
545
+ const dispatchGlyphComputeFast = (bottom_frame, top_frame, proportion) => {
546
+ const renderer = this.region?.getScene?.()?.getRenderer?.();
547
+ if (!renderer) return false;
548
+ glyphCompute.uniforms.bottomFrame.value = bottom_frame;
549
+ glyphCompute.uniforms.topFrame.value = top_frame;
550
+ glyphCompute.uniforms.proportion.value = proportion;
551
+ glyphCompute.uniforms.globalScale.value = this.globalScale;
552
+ renderer.compute(glyphCompute.compute);
553
+ boundsAreExact = false;
554
+ this.morph.raycast = () => {};
555
+ renderBuffersInitialized = true;
556
+ return true;
557
+ };
558
+
559
+ /**
560
+ * The readback half of the debounced accurate resync (see
561
+ * scheduleAccurateResync): unlike dispatchGlyphCompute(), this does NOT
562
+ * call renderer.compute() first - by the time this fires, the buffers
563
+ * already hold the right result.
564
+ */
565
+ const readbackGlyphComputeOnly = () => {
566
+ if (glyphComputePending) return;
567
+ const renderer = this.region?.getScene?.()?.getRenderer?.();
568
+ if (!renderer) return;
569
+ glyphComputePending = true;
570
+ readbackGlyphTransform(renderer, glyphCompute).then((result) => {
571
+ glyphComputePending = false;
572
+ applyGlyphComputeResult(result);
573
+ boundsAreExact = true;
574
+ delete this.morph.raycast;
575
+ }).catch((err) => {
576
+ glyphComputePending = false;
577
+ console.error("Glyph transform compute readback failed.", err);
578
+ });
579
+ };
580
+
581
+ const cancelScheduledAccurateResync = () => {
582
+ if (accurateResyncTimer !== undefined) {
583
+ clearTimeout(accurateResyncTimer);
584
+ accurateResyncTimer = undefined;
585
+ }
586
+ };
587
+
588
+ /**
589
+ * (Re)schedules a single accurate resync ACCURATE_RESYNC_DEBOUNCE_MS from
590
+ * now, cancelling any previously scheduled one.
591
+ */
592
+ const scheduleAccurateResync = () => {
593
+ cancelScheduledAccurateResync();
594
+ accurateResyncTimer = setTimeout(() => {
595
+ accurateResyncTimer = undefined;
596
+ readbackGlyphComputeOnly();
597
+ }, ACCURATE_RESYNC_DEBOUNCE_MS);
325
598
  };
326
599
 
327
600
  /**
328
601
  * Update the current states of the glyphs in this glyphset, this includes transformation and
329
602
  * colour for each of them. This is called when glyphset and glyphs are initialised and whenever
330
603
  * the internal time has been updated.
604
+ *
605
+ * @param {Boolean} debounceAccurateResync - When true the costlier GPU->CPU readback
606
+ * (bounding box/raycast/ export accuracy - see dispatchGlyphCompute) is debounced
607
+ * until updates have been quiet for ACCURATE_RESYNC_DEBOUNCE_MS. When false/omitted
608
+ * (e.g. the render loop's animation-just-stopped resync), the readback happens
609
+ * immediately, as before.
331
610
  */
332
- const updateMorphGlyphsets = () => {
611
+ const updateMorphGlyphsets = (debounceAccurateResync) => {
612
+ const current_time = this.inbuildTime / this.duration * (numberOfTimeSteps - 1);
613
+ const bottom_frame = Math.floor(current_time);
614
+ const proportion = 1 - (current_time - bottom_frame);
615
+ const top_frame = Math.ceil(current_time);
616
+
617
+ if (morphVertices && glyphCompute) {
618
+ if (debounceAccurateResync) {
619
+ dispatchGlyphComputeFast(bottom_frame, top_frame, proportion);
620
+ scheduleAccurateResync();
621
+ } else {
622
+ cancelScheduledAccurateResync();
623
+ // GPU path: transform (and colour, if morphColours) for this frame -
624
+ // handles both updateGlyphsetTransformation() and
625
+ // updateGlyphsetHexColors() below in one dispatch.
626
+ dispatchGlyphCompute(bottom_frame, top_frame, proportion);
627
+ }
628
+ return;
629
+ }
630
+
333
631
  let current_positions = _current_positions;
334
632
  let current_axis1s = _current_axis1s;
335
633
  let current_axis2s = _current_axis2s;
@@ -337,10 +635,6 @@ const Glyphset = function () {
337
635
  let current_scales = _current_scales;
338
636
  let current_colors = _current_colors;
339
637
 
340
- const current_time = this.inbuildTime / this.duration * (numberOfTimeSteps - 1);
341
- const bottom_frame = Math.floor(current_time);
342
- const proportion = 1 - (current_time - bottom_frame);
343
- const top_frame = Math.ceil(current_time);
344
638
  if (morphVertices) {
345
639
  const bottom_positions = positions[bottom_frame.toString()];
346
640
  const top_positions = positions[top_frame.toString()];
@@ -387,15 +681,10 @@ const Glyphset = function () {
387
681
  _bot_colour.b * proportion + _top_colour.b * (1 - proportion));
388
682
  current_colors[i] = _bot_colour.getHex();
389
683
  }
390
- /*
391
- for (var i = 0; i < bottom_colors.length; i++) {
392
- current_colors.push(proportion * bottom_colors[i] + (1.0 - proportion) * top_colors[i]);
393
- }
394
- */
395
684
  } else {
396
685
  current_colors = colors["0"];
397
686
  }
398
- updateGlyphsetHexColors(current_colors);
687
+ updateGlyphsetHexColors(current_colors, false);
399
688
  }
400
689
  };
401
690
 
@@ -442,6 +731,53 @@ const Glyphset = function () {
442
731
  labelsOn = false;
443
732
  }
444
733
 
734
+ /**
735
+ * Computes a single Box3 that conservatively contains every keyframe's
736
+ * exact per-instance transform of the base glyph geometry, for use as
737
+ * getBoundingBox()'s answer while boundsAreExact is false (see its
738
+ * declaration above for the caveat on this not being a formally proven
739
+ * bound). One-time cost at load, reusing resolve_glyph_axes() the same
740
+ * way updateGlyphsetTransformation() does, just for every keyframe
741
+ * instead of just the current one, unioned together.
742
+ */
743
+ const computeAllFramesBoundingBox = () => {
744
+ const localBox = new THREE.Box3().setFromBufferAttribute(this.geometry.attributes.position);
745
+ const tempMatrix = new THREE.Matrix4();
746
+ const tempBox = new THREE.Box3();
747
+ const tempArray = [];
748
+ const result = new THREE.Box3();
749
+ let first = true;
750
+ for (let t = 0; t < numberOfTimeSteps; t++) {
751
+ const tPositions = positions[t.toString()];
752
+ const tAxis1 = axis1s[t.toString()];
753
+ const tAxis2 = axis2s[t.toString()];
754
+ const tAxis3 = axis3s[t.toString()];
755
+ const tScales = scales[t.toString()];
756
+ if (!tPositions) continue;
757
+ const numberOfPositions = tPositions.length / 3;
758
+ for (let i = 0; i < numberOfPositions; i++) {
759
+ const idx = i * 3;
760
+ const point = [tPositions[idx], tPositions[idx + 1], tPositions[idx + 2]];
761
+ const a1 = [tAxis1[idx], tAxis1[idx + 1], tAxis1[idx + 2]];
762
+ const a2 = [tAxis2[idx], tAxis2[idx + 1], tAxis2[idx + 2]];
763
+ const a3 = [tAxis3[idx], tAxis3[idx + 1], tAxis3[idx + 2]];
764
+ const sc = [tScales[idx], tScales[idx + 1], tScales[idx + 2]];
765
+ const arrays = resolve_glyph_axes(point, a1, a2, a3, sc, tempArray);
766
+ for (let j = 0; j < arrays.length; j++) {
767
+ writeTransformMatrix(tempMatrix, arrays[j]);
768
+ tempBox.copy(localBox).applyMatrix4(tempMatrix);
769
+ if (first) {
770
+ result.copy(tempBox);
771
+ first = false;
772
+ } else {
773
+ result.union(tempBox);
774
+ }
775
+ }
776
+ }
777
+ }
778
+ return first ? undefined : result;
779
+ }
780
+
445
781
  /**
446
782
  * Create the glyphs in the glyphset.
447
783
  *
@@ -468,9 +804,11 @@ const Glyphset = function () {
468
804
  //Update the transformation of the glyphs.
469
805
  updateGlyphsetTransformation(positions["0"], axis1s["0"],
470
806
  axis2s["0"], axis3s["0"], scales["0"]);
471
- //Update the color of the glyphs.
472
807
  if (colors != undefined) {
473
- updateGlyphsetHexColors(colors["0"]);
808
+ updateGlyphsetHexColors(colors["0"], !!(glyphCompute && glyphCompute.outputs.color));
809
+ }
810
+ if (glyphCompute) {
811
+ allFramesBoundingBox = computeAllFramesBoundingBox();
474
812
  }
475
813
  this.ready = true;
476
814
  };
@@ -521,11 +859,18 @@ const Glyphset = function () {
521
859
 
522
860
  var meshloader = (finishCallback, displayLabels) => {
523
861
  return (geometry, materials) => {
862
+ if (disposed) {
863
+ geometry.dispose();
864
+ return;
865
+ }
524
866
  this.geometry.copy(geometry);
525
867
  this.geometry.computeBoundingSphere();
526
868
  this.geometry.computeBoundingBox();
527
869
  if (materials && materials[0])
528
870
  this.morph.material = materials[0];
871
+ if (glyphCompute) {
872
+ this.morph.material = createGlyphInstancedMaterial(this.morph.material, glyphCompute);
873
+ }
529
874
  createGlyphs(displayLabels);
530
875
  this.morph.name = this.groupName;
531
876
  this.morph.userData = this;
@@ -597,6 +942,12 @@ const Glyphset = function () {
597
942
  */
598
943
  this.getBoundingBox = () => {
599
944
  if (this.morph && this.ready && this.morph.visible) {
945
+ if (glyphCompute && !boundsAreExact && allFramesBoundingBox) {
946
+ this.cachedBoundingBox.copy(allFramesBoundingBox);
947
+ this.morph.updateWorldMatrix(true, true);
948
+ this.cachedBoundingBox.applyMatrix4(this.morph.matrixWorld);
949
+ return this.cachedBoundingBox;
950
+ }
600
951
  if (this.boundingBoxUpdateRequired) {
601
952
  _boundingBox1.setFromBufferAttribute(
602
953
  this.morph.geometry.attributes.position);
@@ -635,7 +986,7 @@ const Glyphset = function () {
635
986
  else
636
987
  this.inbuildTime = time;
637
988
  if (morphColours || morphVertices) {
638
- updateMorphGlyphsets();
989
+ updateMorphGlyphsets(true);
639
990
  if (morphVertices)
640
991
  this.markerUpdateRequired = true;
641
992
  }
@@ -662,6 +1013,49 @@ const Glyphset = function () {
662
1013
  return this.inbuildTime;
663
1014
  }
664
1015
 
1016
+ /**
1017
+ * Exporters (see sceneExporter.js/GLTFExporter.js) read
1018
+ * THREE.InstancedMesh.instanceColor directly off this.morph - which
1019
+ * createGlyphs() intentionally leaves null whenever colour is
1020
+ * glyphCompute-driven. No-op for glyphsets whose colour isn't glyphCompute-driven
1021
+ * (instanceColor there is already accurate, ordinary CPU-driven state).
1022
+ *
1023
+ * @return {Promise}
1024
+ */
1025
+ this.prepareColorForExport = async () => {
1026
+ if (!(glyphCompute && glyphCompute.outputs.color)) return;
1027
+ const renderer = this.region?.getScene?.()?.getRenderer?.();
1028
+ if (!renderer) return;
1029
+ renderer.compute(glyphCompute.compute);
1030
+ const colorResult = new Float32Array(
1031
+ await renderer.getArrayBufferAsync(glyphCompute.outputs.color.value));
1032
+ // setColorAt(0, ...) is only here to lazily allocate instanceColor the
1033
+ // same way the stock InstancedMesh API would; the loop below overwrites
1034
+ // every instance (including 0) with the real snapshot right after.
1035
+ this.morph.setColorAt(0, _bot_colour);
1036
+ const colorArray = this.morph.instanceColor.array;
1037
+ for (let i = 0; i < numberOfVertices; i++) {
1038
+ const o4 = i * 4;
1039
+ const o3 = i * 3;
1040
+ colorArray[o3] = colorResult[o4];
1041
+ colorArray[o3 + 1] = colorResult[o4 + 1];
1042
+ colorArray[o3 + 2] = colorResult[o4 + 2];
1043
+ }
1044
+ this.morph.instanceColor.needsUpdate = true;
1045
+ }
1046
+
1047
+ /**
1048
+ * Undoes prepareColorForExport() - see its comment for why this matters:
1049
+ * without it, the next live render would permanently pick up the stale
1050
+ * instanceColor multiply bug this whole design avoids.
1051
+ */
1052
+ this.clearColorExportState = () => {
1053
+ if (glyphCompute && glyphCompute.outputs.color && this.morph.instanceColor) {
1054
+ this.morph.instanceColor = null;
1055
+ if (this.morph.material) this.morph.material.needsUpdate = true;
1056
+ }
1057
+ }
1058
+
665
1059
  /**
666
1060
  * Set the objects scale.
667
1061
  *
@@ -669,13 +1063,14 @@ const Glyphset = function () {
669
1063
  */
670
1064
  this.setScaleAll = function(scale) {
671
1065
  this.globalScale = scale;
672
- updateMorphGlyphsets();
1066
+ updateMorphGlyphsets(false);
673
1067
  }
674
1068
 
675
1069
  /**
676
1070
  * Clear this glyphset and its list of glyphs which will release them from the memory.
677
1071
  */
678
1072
  this.dispose = () => {
1073
+ disposed = true;
679
1074
  for (let i = glyphList.length - 1; i >= 0; i--) {
680
1075
  glyphList[i].dispose();
681
1076
  }
@@ -683,12 +1078,19 @@ const Glyphset = function () {
683
1078
  this.geometry.dispose();
684
1079
  if (this.morph)
685
1080
  this.morph.material.dispose();
1081
+ cancelScheduledAccurateResync();
686
1082
  axis1s = undefined;
687
1083
  axis2s = undefined;
688
1084
  axis3s = undefined;
689
1085
  positions = undefined;
690
1086
  scales = undefined;
691
1087
  colors = undefined;
1088
+ glyphCompute = undefined;
1089
+ glyphComputePending = false;
1090
+ boundsAreExact = true;
1091
+ renderBuffersInitialized = false;
1092
+ wasAnimating = false;
1093
+ allFramesBoundingBox = undefined;
692
1094
  this.ready = false;
693
1095
  this.groupName = undefined;
694
1096
  }
@@ -697,14 +1099,42 @@ const Glyphset = function () {
697
1099
  * Update the glyphsets if required the render.
698
1100
  */
699
1101
  this.render = (delta, playAnimation, options) => {
1102
+ if (glyphCompute && !renderBuffersInitialized) {
1103
+ const renderer = this.region?.getScene?.()?.getRenderer?.();
1104
+ if (renderer) {
1105
+ renderer.compute(glyphCompute.compute);
1106
+ renderBuffersInitialized = true;
1107
+ }
1108
+ }
700
1109
  if (playAnimation == true) {
701
1110
  let targetTime = this.inbuildTime + delta;
702
1111
  if (targetTime > this.duration)
703
1112
  targetTime = targetTime - this.duration;
704
1113
  this.inbuildTime = targetTime;
705
- if (morphColours || morphVertices) {
706
- updateMorphGlyphsets();
1114
+ if (morphVertices && glyphCompute) {
1115
+ // Fast GPU-only path: update the render buffers every frame with
1116
+ // no CPU readback. Colour (if morphColours) rides along in the
1117
+ // same compute pass/buffers - see glyphRenderMaterial.js's colorNode.
1118
+ // Also drop any accurate resync a prior setMorphTime() call (e.g. a
1119
+ // downstream slider) left debounced-and-pending - it would apply a
1120
+ // now-stale frame once it fired mid-animation otherwise.
1121
+ cancelScheduledAccurateResync();
1122
+ const current_time = this.inbuildTime / this.duration * (numberOfTimeSteps - 1);
1123
+ const bottom_frame = Math.floor(current_time);
1124
+ const proportion = 1 - (current_time - bottom_frame);
1125
+ const top_frame = Math.ceil(current_time);
1126
+ dispatchGlyphComputeFast(bottom_frame, top_frame, proportion);
1127
+ } else if (morphColours || morphVertices) {
1128
+ updateMorphGlyphsets(false);
1129
+ }
1130
+ wasAnimating = true;
1131
+ } else {
1132
+ if (wasAnimating && glyphCompute) {
1133
+ // Just stopped: one accurate resync so bounding-box/raycast are
1134
+ // exact again for as long as playback stays paused.
1135
+ updateMorphGlyphsets(false);
707
1136
  }
1137
+ wasAnimating = false;
708
1138
  }
709
1139
  this.updateMarker(playAnimation, options);
710
1140
  }
@@ -1,6 +1,6 @@
1
1
  import * as THREE from 'three/webgpu';
2
2
  import { toBufferGeometry } from '../utilities';
3
- import { applyMorphColorNode } from '../tls/morphColorMaterial';
3
+ import { applyMorphColorNode } from '../tsl/morphColorMaterial';
4
4
  import { LineSegments } from '../three/line/LineSegments';
5
5
  import { ZincObject } from './zincObject';
6
6