Skip to content

Commit d46a377

Browse files
committed
fix: use InstancedMesh for CaloCells to prevent WebGL crash (#474)
Signed-off-by: rx18-eng <remopanda78@gmail.com>
1 parent 9632c1b commit d46a377

7 files changed

Lines changed: 363 additions & 8 deletions

File tree

packages/phoenix-event-display/src/loaders/object-type-registry.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ export function getDefaultObjectTypeConfigs(): ObjectTypeConfig[] {
8989
},
9090
{
9191
typeName: 'CaloCells',
92-
getObject: PhoenixObjects.getCaloCell,
92+
getObject: PhoenixObjects.getCaloCellsInstanced,
93+
concatonateObjs: true,
9394
cuts: [
9495
new Cut('phi', -pi, pi, 0.01),
9596
new Cut('eta', -5.0, 5.0, 0.1),
@@ -98,7 +99,7 @@ export function getDefaultObjectTypeConfigs(): ObjectTypeConfig[] {
9899
scaleConfig: {
99100
key: 'caloCellsScale',
100101
label: 'CaloCells Scale',
101-
scaleMethod: 'scaleChildObjects',
102+
scaleMethod: 'scaleInstancedObjects',
102103
scaleAxis: 'z',
103104
},
104105
},

packages/phoenix-event-display/src/loaders/objects/phoenix-objects.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
LineSegments,
2323
LineDashedMaterial,
2424
CanvasTexture,
25+
InstancedMesh,
26+
Matrix4,
2527
} from 'three';
2628
import { ConvexGeometry } from 'three/examples/jsm/geometries/ConvexGeometry.js';
2729
import { EVENT_DATA_TYPE_COLORS } from '../../helpers/constants';
@@ -709,6 +711,102 @@ export class PhoenixObjects {
709711
return cube;
710712
}
711713

714+
/**
715+
* Create all CaloCells as a single InstancedMesh for performance.
716+
* Receives the entire collection array and returns one object with
717+
* per-instance transforms and colors, reducing 187K draw calls to 1.
718+
* @param cellsParams Array of all cell parameters in the collection.
719+
* @returns InstancedMesh containing all cells.
720+
*/
721+
public static getCaloCellsInstanced(cellsParams: any[]): Object3D {
722+
const defaultRadius = 1700;
723+
const defaultZ = 2000;
724+
const defaultSide = 30;
725+
const defaultLength = 30;
726+
727+
const count = cellsParams.length;
728+
const unitBox = new BoxGeometry(1, 1, 1);
729+
const material = new MeshPhongMaterial({
730+
color: cellsParams[0]?.color ?? EVENT_DATA_TYPE_COLORS.CaloClusters,
731+
transparent: true,
732+
opacity: 0.7,
733+
});
734+
735+
const mesh = new InstancedMesh(unitBox, material, count);
736+
737+
// Reusable temporaries to avoid per-iteration allocation
738+
const tempMatrix = new Matrix4();
739+
const tempPosition = new Vector3();
740+
const tempScale = new Vector3();
741+
const tempColor = new Color();
742+
const tempObj = new Object3D();
743+
744+
for (let i = 0; i < count; i++) {
745+
const cell = cellsParams[i];
746+
747+
// Position (reuse existing helper)
748+
const position = PhoenixObjects.getCaloPosition(
749+
cell,
750+
defaultRadius,
751+
defaultZ,
752+
);
753+
754+
// Cell dimensions — scale encodes variable width/length
755+
const cellWidth = cell.side ?? defaultSide;
756+
let cellLength = cell.length ?? defaultLength;
757+
if (cellLength < cellWidth) {
758+
cellLength = cellWidth;
759+
}
760+
761+
// Orientation via lookAt (same 3-branch logic as getCaloCell)
762+
tempObj.position.copy(position);
763+
tempObj.rotation.set(0, 0, 0);
764+
tempObj.updateMatrix();
765+
if (!cell.radius && !cell.z) {
766+
tempObj.lookAt(0, 0, 0);
767+
} else if (cell.z && !cell.radius) {
768+
tempObj.lookAt(position.x, position.y, 0);
769+
}
770+
if (cell.radius) {
771+
tempObj.lookAt(0, 0, position.z);
772+
}
773+
774+
// Compose transform: position + orientation + scale
775+
tempScale.set(cellWidth, cellWidth, cellLength);
776+
tempMatrix.compose(position, tempObj.quaternion, tempScale);
777+
mesh.setMatrixAt(i, tempMatrix);
778+
779+
// Per-instance color
780+
tempColor.set(cell.color ?? EVENT_DATA_TYPE_COLORS.CaloClusters);
781+
mesh.setColorAt(i, tempColor);
782+
783+
// Write back identifiers for filtering and collection lookups
784+
cell._instanceId = i;
785+
cell.uuid = mesh.uuid;
786+
}
787+
788+
mesh.instanceMatrix.needsUpdate = true;
789+
if (mesh.instanceColor) {
790+
mesh.instanceColor.needsUpdate = true;
791+
}
792+
793+
// Compute bounding sphere across all instances for correct frustum culling.
794+
// Without this, Three.js uses the unit-box bounding sphere and cells
795+
// disappear when panning.
796+
mesh.computeBoundingSphere();
797+
798+
mesh.name = 'CaloCellInstanced';
799+
mesh.userData = {
800+
_isInstancedCaloCells: true,
801+
_instanceData: cellsParams,
802+
_originalMatrices: null, // lazily populated on first filter
803+
_scaleValue: 1, // current scale factor (for filter↔scale coordination)
804+
_scaleAxis: null as string | null,
805+
};
806+
807+
return mesh;
808+
}
809+
712810
/**
713811
* Get the planar calo cells from parameters.
714812
* @param caloCells Parameters to build planar calo cells.

packages/phoenix-event-display/src/managers/three-manager/controls-manager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Group,
99
Scene,
1010
Mesh,
11+
InstancedMesh,
1112
TubeGeometry,
1213
MathUtils,
1314
} from 'three';
@@ -704,6 +705,10 @@ export class ControlsManager {
704705
}
705706
}
706707
});
708+
} else if (object instanceof InstancedMesh) {
709+
// InstancedMesh: compute bounding sphere across all instances
710+
object.computeBoundingSphere();
711+
objectPosition = object.boundingSphere?.center ?? new Vector3();
707712
} else if (object.position.equals(origin)) {
708713
// Get the center of bounding sphere of objects with no position
709714
objectPosition = object.geometry?.boundingSphere?.center;

packages/phoenix-event-display/src/managers/three-manager/scene-manager.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
TubeGeometry,
2323
MeshToonMaterial,
2424
Line,
25+
InstancedMesh,
26+
Matrix4,
2527
type Object3DEventMap,
2628
} from 'three';
2729
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
@@ -298,6 +300,15 @@ export class SceneManager {
298300
const collection = eventData.getObjectByName(collectionName);
299301
if (collection) {
300302
for (const child of Object.values(collection.children)) {
303+
// InstancedMesh: filter by scaling hidden instances to zero
304+
if (
305+
child.userData?._isInstancedCaloCells &&
306+
child instanceof InstancedMesh
307+
) {
308+
this.filterInstancedMesh(child, filters);
309+
continue;
310+
}
311+
301312
if (child.userData) {
302313
for (const filter of filters) {
303314
const value = child.userData[filter.field];
@@ -915,6 +926,143 @@ export class SceneManager {
915926
});
916927
}
917928

929+
/**
930+
* Filter an InstancedMesh by setting hidden instances to zero-scale.
931+
* Respects current scale factor so filter and scale don't conflict.
932+
* @param mesh The InstancedMesh to filter.
933+
* @param filters Cuts used to determine visibility of each instance.
934+
*/
935+
private filterInstancedMesh(mesh: InstancedMesh, filters: Cut[]) {
936+
const instanceData: any[] = mesh.userData._instanceData;
937+
if (!instanceData) return;
938+
939+
this.ensureOriginalMatrices(mesh);
940+
941+
const originalMatrices: Float32Array = mesh.userData._originalMatrices;
942+
const scaleValue: number = mesh.userData._scaleValue ?? 1;
943+
const scaleAxis: string | null = mesh.userData._scaleAxis ?? null;
944+
const zeroMatrix = new Matrix4().makeScale(0, 0, 0);
945+
const tempMatrix = new Matrix4();
946+
const pos = new Vector3();
947+
const quat = new Quaternion();
948+
const scl = new Vector3();
949+
950+
for (let i = 0; i < instanceData.length; i++) {
951+
const cell = instanceData[i];
952+
let visible = true;
953+
954+
for (const filter of filters) {
955+
const value = cell[filter.field];
956+
if (value !== undefined && !filter.cutPassed(value)) {
957+
visible = false;
958+
break;
959+
}
960+
}
961+
962+
if (visible) {
963+
tempMatrix.fromArray(originalMatrices, i * 16);
964+
// Re-apply current scale factor so filtering doesn't reset scale
965+
if (scaleValue !== 1) {
966+
tempMatrix.decompose(pos, quat, scl);
967+
this.applyAxisScale(scl, scaleValue, scaleAxis);
968+
tempMatrix.compose(pos, quat, scl);
969+
}
970+
mesh.setMatrixAt(i, tempMatrix);
971+
} else {
972+
mesh.setMatrixAt(i, zeroMatrix);
973+
}
974+
}
975+
976+
mesh.instanceMatrix.needsUpdate = true;
977+
}
978+
979+
/**
980+
* Scale instances in an InstancedMesh along an axis.
981+
* Stores scale state so filtering preserves it. Skips zero-scaled (filtered) instances.
982+
* Falls back to scaleChildObjects for non-instanced groups.
983+
* @param groupName Name of the group containing the InstancedMesh.
984+
* @param value Scale factor (1 = original size).
985+
* @param axis Optional axis to scale along ('x', 'y', 'z').
986+
*/
987+
public scaleInstancedObjects(
988+
groupName: string,
989+
value: number,
990+
axis?: string,
991+
) {
992+
const object = this.scene.getObjectByName(groupName);
993+
if (!object) return;
994+
995+
let handled = false;
996+
object.traverse((child: Object3D) => {
997+
if (
998+
child.userData?._isInstancedCaloCells &&
999+
child instanceof InstancedMesh
1000+
) {
1001+
handled = true;
1002+
const instanceData: any[] = child.userData._instanceData;
1003+
if (!instanceData) return;
1004+
1005+
this.ensureOriginalMatrices(child);
1006+
1007+
// Store scale state for filter coordination
1008+
child.userData._scaleValue = value;
1009+
child.userData._scaleAxis = axis ?? null;
1010+
1011+
const originalMatrices: Float32Array = child.userData._originalMatrices;
1012+
const tempMatrix = new Matrix4();
1013+
const currentMatrix = new Matrix4();
1014+
const pos = new Vector3();
1015+
const quat = new Quaternion();
1016+
const scl = new Vector3();
1017+
1018+
for (let i = 0; i < instanceData.length; i++) {
1019+
// Skip instances that are currently filtered out (zero-scale)
1020+
child.getMatrixAt(i, currentMatrix);
1021+
const e = currentMatrix.elements;
1022+
if (e[0] === 0 && e[5] === 0 && e[10] === 0) continue;
1023+
1024+
tempMatrix.fromArray(originalMatrices, i * 16);
1025+
tempMatrix.decompose(pos, quat, scl);
1026+
this.applyAxisScale(scl, value, axis);
1027+
tempMatrix.compose(pos, quat, scl);
1028+
child.setMatrixAt(i, tempMatrix);
1029+
}
1030+
1031+
child.instanceMatrix.needsUpdate = true;
1032+
}
1033+
});
1034+
1035+
// Fallback for non-instanced objects in the same group
1036+
if (!handled) {
1037+
this.scaleChildObjects(groupName, value, axis);
1038+
}
1039+
}
1040+
1041+
/** Snapshot the original instance matrices on first use. */
1042+
private ensureOriginalMatrices(mesh: InstancedMesh) {
1043+
if (!mesh.userData._originalMatrices) {
1044+
mesh.userData._originalMatrices =
1045+
mesh.instanceMatrix.array.slice() as Float32Array;
1046+
}
1047+
}
1048+
1049+
/** Apply a scale factor to a Vector3 along a specific axis or uniformly. */
1050+
private applyAxisScale(scl: Vector3, value: number, axis?: string | null) {
1051+
switch (axis) {
1052+
case 'x':
1053+
scl.x *= value;
1054+
break;
1055+
case 'y':
1056+
scl.y *= value;
1057+
break;
1058+
case 'z':
1059+
scl.z *= value;
1060+
break;
1061+
default:
1062+
scl.multiplyScalar(value);
1063+
}
1064+
}
1065+
9181066
/**
9191067
* Add label to the three.js object.
9201068
* @param label Label to add to the event object.

packages/phoenix-event-display/src/managers/three-manager/selection-manager.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
AmbientLight,
1010
AxesHelper,
1111
Mesh,
12+
InstancedMesh,
1213
} from 'three';
1314
import { Easing, Group as TweenGroup, Tween } from '@tweenjs/tween.js';
1415
import { InfoLogger } from '../../helpers/info-logger';
@@ -559,6 +560,15 @@ export class SelectionManager {
559560
if (targetObject !== this.hoveredObject) {
560561
this.hoveredObject = targetObject;
561562

563+
// InstancedMesh CaloCells: show info panel but skip hover outline
564+
// (can't outline individual instances within an InstancedMesh)
565+
if (targetObject?.userData?._isInstancedCaloCells) {
566+
this.effectsManager.setHoverOutline(null);
567+
this.updateInfoPanelForHover(targetObject);
568+
this.currentlyOutlinedObject = null;
569+
return;
570+
}
571+
562572
// Set hover outline (this is separate from sticky selections)
563573
this.effectsManager.setHoverOutline(targetObject);
564574

@@ -650,6 +660,16 @@ export class SelectionManager {
650660
*/
651661
private updateInfoPanelForHover(object: Mesh | null) {
652662
if (object) {
663+
// InstancedMesh CaloCells: read per-instance data
664+
let userData = object.userData;
665+
if (
666+
userData?._isInstancedCaloCells &&
667+
userData._instanceData &&
668+
userData._lastHitInstanceId !== undefined
669+
) {
670+
userData = userData._instanceData[userData._lastHitInstanceId] ?? {};
671+
}
672+
653673
// Object is being hovered - update info panel
654674
this.selectedObject.name = object.name;
655675
this.selectedObject.attributes.splice(
@@ -658,7 +678,7 @@ export class SelectionManager {
658678
);
659679
this.activeObject.update(object.uuid);
660680

661-
const prettyParams = PrettySymbols.getPrettyParams(object.userData);
681+
const prettyParams = PrettySymbols.getPrettyParams(userData);
662682

663683
for (const key of Object.keys(prettyParams)) {
664684
this.selectedObject.attributes.push({
@@ -814,7 +834,19 @@ export class SelectionManager {
814834
// Perform intersection test
815835
const intersects = raycaster.intersectObjects(intersectableObjects, false);
816836

817-
return intersects.length > 0 ? intersects[0].object : null;
837+
if (intersects.length === 0) return null;
838+
839+
const hit = intersects[0];
840+
// For InstancedMesh CaloCells, store the hit instanceId for downstream use
841+
if (
842+
hit.object.userData?._isInstancedCaloCells &&
843+
hit.object instanceof InstancedMesh &&
844+
hit.instanceId !== undefined
845+
) {
846+
hit.object.userData._lastHitInstanceId = hit.instanceId;
847+
}
848+
849+
return hit.object;
818850
}
819851

820852
/**
@@ -923,6 +955,9 @@ export class SelectionManager {
923955
public highlightObject(uuid: string, objectsGroup: Object3D) {
924956
const object = objectsGroup.getObjectByProperty('uuid', uuid);
925957
if (object && object instanceof Mesh) {
958+
// Skip OutlinePass for instanced CaloCells (can't outline individual instances)
959+
if (object.userData?._isInstancedCaloCells) return;
960+
926961
// Use the modern selection system instead of legacy outline
927962
this.effectsManager.selectObject(object);
928963
this.selectedObjects.add(object);

0 commit comments

Comments
 (0)