zincjs 2.4.1-beta.2 → 2.4.1-beta.3

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