Warning, /firebird/packages/firebird-core/src/painters/batched-trajectory.painter.ts is written in an unsupported language. File is not indexed.
0001 import {EventPiecePainter, PainterConfigView, PainterMeta} from "./event-piece-painter";
0002 import {EventPiece} from "../model/event-piece";
0003 import {PointTrajectoryPiece} from "../model/point-trajectory.piece";
0004 import {NeonTrackColors, pidColorFor, pidIsDashed} from "./trajectory.painter";
0005
0006 import {Color, Object3D, Intersection} from "three";
0007 // Import from three/webgpu, NEVER from three/src/... — see trajectory.painter.ts
0008 import {Line2NodeMaterial} from "three/webgpu";
0009 import {LineSegmentsGeometry} from "three/examples/jsm/lines/LineSegmentsGeometry.js";
0010 import {LineSegments2} from "three/examples/jsm/lines/webgpu/LineSegments2.js";
0011
0012 /**
0013 * One draw call worth of segments: every trajectory of the piece that shares
0014 * a dash style lives in a single LineSegments2. Segments are sorted by their
0015 * START time, so revealing the event up to time t is one instanceCount write
0016 * (the same segment granularity the per-track painter reveals with).
0017 */
0018 interface SegmentBatch {
0019 object: LineSegments2;
0020 geometry: LineSegmentsGeometry;
0021 material: Line2NodeMaterial;
0022 /** Total segments in the batch. */
0023 count: number;
0024 /** Per segment, ascending: the segment's start-point time. */
0025 startTimes: Float32Array;
0026 /** Per segment (sorted order): the trajectory id it belongs to. */
0027 trackIds: Uint32Array;
0028 /** Per segment (sorted order): 6 floats, start rgb + end rgb. */
0029 colors: Float32Array;
0030 dashed: boolean;
0031 }
0032
0033 /**
0034 * Alternative painter for "PointTrajectory" pieces that batches ALL
0035 * trajectories into two objects (solid + dashed) instead of one Line2 per
0036 * trajectory.
0037 *
0038 * Why: the per-track painter costs one scene object + one material per
0039 * trajectory. Measured on a 53k-trajectory background frame that is ~0.8 s of
0040 * construction and, far worse, >10 s PER FRAME of draw submission — the
0041 * renderer, not the build, is what freezes the browser. Batched, the same
0042 * piece renders in 2 draw calls with 2 materials.
0043 *
0044 * Select it per piece through the normal painter selection config:
0045 * `painters.byPiece.<pieceName> = trajectory-lines-batched` (panel dropdown,
0046 * yaml, or URL `?config.painters.byPiece.MCParticles=trajectory-lines-batched`)
0047 * — the per-track painter stays registered, so the two are switchable live
0048 * for comparison.
0049 *
0050 * Batching trade-offs vs the per-track painter:
0051 * - per-track coloring is kept (per-segment color attribute; material color
0052 * stays white because vertex colors MULTIPLY it);
0053 * - line width and dash pattern are per MATERIAL, so all tracks of a batch
0054 * share them (optical photons lose their thinner-line special case);
0055 * - highlight recolors the track's segments (no width change);
0056 * - 3D picking cannot use one-object-per-entity stamps — the batch object
0057 * carries an `entityIndexResolver` that maps the picked segment
0058 * (intersection.faceIndex) back to the trajectory id.
0059 */
0060 export class BatchedTrajectoryPainter extends EventPiecePainter {
0061
0062 static meta: PainterMeta = {
0063 id: 'trajectory-lines-batched',
0064 forPieceTypes: [PointTrajectoryPiece.type],
0065 label: 'Trajectory lines (batched)',
0066 configs: [
0067 { key: 'colorMode', default: 'pid', options: ['pid', 'momentum', 'solid'], label: 'Coloring' },
0068 { key: 'lineWidth', default: 30, min: 1, max: 300, step: 1, label: 'Line width [mm]' },
0069 { key: 'color', default: '#00b7ff', label: 'Solid color' },
0070 ],
0071 };
0072
0073 public readonly trackColorHighlight = 0xff4081;
0074
0075 private batches: SegmentBatch[] = [];
0076 private timeIndex = -1;
0077 /** Momentum ceiling of the piece, for the momentum color scale. */
0078 private maxMomentum = 0;
0079
0080 constructor(parentNode: Object3D, piece: EventPiece, config?: PainterConfigView) {
0081 super(parentNode, piece, config);
0082 if (piece.type !== PointTrajectoryPiece.type) {
0083 throw new Error("Wrong piece type given to BatchedTrajectoryPainter.");
0084 }
0085 this.timeIndex = (piece as PointTrajectoryPiece).pointColumns.indexOf('t');
0086 this.buildBatches();
0087 this.applyStyles();
0088 }
0089
0090 private get trajectoryPiece(): PointTrajectoryPiece {
0091 return this.piece as PointTrajectoryPiece;
0092 }
0093
0094 /** Whether this track renders in the dashed batch (fixed at build, like the per-track painter). */
0095 private trackIsDashed(trackIndex: number): boolean {
0096 const pdg = this.trackParamNumber('pdg', trackIndex);
0097 return pidIsDashed(pdg === null ? 0 : Math.floor(pdg));
0098 }
0099
0100 private trackParamNumber(column: string, trackIndex: number): number | null {
0101 const value = this.trajectoryPiece.param(column, trackIndex);
0102 return typeof value === 'number' ? value : null;
0103 }
0104
0105 private buildBatches(): void {
0106 const piece = this.trajectoryPiece;
0107 const buildStart = performance.now();
0108
0109 // Pass 1: count segments per dash style
0110 const counts = [0, 0]; // [solid, dashed]
0111 const dashedOf = new Uint8Array(piece.count);
0112 for (let trackIndex = 0; trackIndex < piece.count; trackIndex++) {
0113 const points = piece.points[trackIndex];
0114 if (points.length <= 1) continue;
0115 const dashed = this.trackIsDashed(trackIndex) ? 1 : 0;
0116 dashedOf[trackIndex] = dashed;
0117 counts[dashed] += points.length - 1;
0118 }
0119
0120 for (const dashed of [0, 1]) {
0121 const count = counts[dashed];
0122 if (count === 0) continue;
0123
0124 // Pass 2: collect (time, trackId, pointIndex) per segment, unsorted
0125 const times = new Float32Array(count);
0126 const segTrack = new Uint32Array(count);
0127 const segPoint = new Uint32Array(count);
0128 let fill = 0;
0129 for (let trackIndex = 0; trackIndex < piece.count; trackIndex++) {
0130 if (dashedOf[trackIndex] !== dashed) continue;
0131 const points = piece.points[trackIndex];
0132 if (points.length <= 1) continue;
0133 for (let j = 0; j < points.length - 1; j++) {
0134 times[fill] = this.timeIndex >= 0 ? points[j][this.timeIndex] : 0;
0135 segTrack[fill] = trackIndex;
0136 segPoint[fill] = j;
0137 fill++;
0138 }
0139 }
0140
0141 // Sort segments by start time so a time cut is one instanceCount write
0142 const order = new Uint32Array(count);
0143 for (let i = 0; i < count; i++) order[i] = i;
0144 if (this.timeIndex >= 0) {
0145 // Typed-array sort with a comparator; ~O(n log n) on segment count
0146 order.sort((a, b) => times[a] - times[b]);
0147 }
0148
0149 const positions = new Float32Array(count * 6);
0150 const startTimes = new Float32Array(count);
0151 const trackIds = new Uint32Array(count);
0152 for (let i = 0; i < count; i++) {
0153 const source = order[i];
0154 const trackIndex = segTrack[source];
0155 const points = piece.points[trackIndex];
0156 const start = points[segPoint[source]];
0157 const end = points[segPoint[source] + 1];
0158 const base = i * 6;
0159 positions[base] = start[0];
0160 positions[base + 1] = start[1];
0161 positions[base + 2] = start[2];
0162 positions[base + 3] = end[0];
0163 positions[base + 4] = end[1];
0164 positions[base + 5] = end[2];
0165 startTimes[i] = times[source];
0166 trackIds[i] = trackIndex;
0167 }
0168
0169 const geometry = new LineSegmentsGeometry();
0170 geometry.setPositions(positions);
0171 const colors = new Float32Array(count * 6);
0172 geometry.setColors(colors); // filled by applyStyles
0173
0174 // Vertex colors MULTIPLY the material color — it must stay white
0175 const material = new Line2NodeMaterial({
0176 color: 0xffffff,
0177 vertexColors: true,
0178 linewidth: this.config.value<number>('lineWidth'),
0179 worldUnits: true,
0180 dashed: dashed === 1,
0181 dashSize: 100,
0182 gapSize: 100,
0183 alphaToCoverage: true,
0184 });
0185
0186 const object = new LineSegments2(geometry, material as any);
0187 if (dashed) object.computeLineDistances();
0188 object.name = `${this.pieceName}_batched_${dashed ? 'dashed' : 'solid'}`;
0189
0190 const batch: SegmentBatch = {
0191 object,
0192 geometry,
0193 material,
0194 count,
0195 startTimes,
0196 trackIds,
0197 colors,
0198 dashed: dashed === 1,
0199 };
0200
0201 // 3D picking: one object holds every track, so the entity is resolved
0202 // from the picked segment index, not from the object itself
0203 object.userData['pieceName'] = this.pieceName;
0204 object.userData['entityIndexResolver'] = (intersection: Intersection) => {
0205 const segment = intersection.faceIndex;
0206 return typeof segment === 'number' && segment < batch.count ? batch.trackIds[segment] : null;
0207 };
0208
0209 this.parentNode.add(object);
0210 this.batches.push(batch);
0211 }
0212
0213 const buildMs = performance.now() - buildStart;
0214 if (buildMs > 10) {
0215 const total = counts[0] + counts[1];
0216 console.log(`[load-timing] BatchedTrajectoryPainter '${this.pieceName}' build ` +
0217 `${buildMs.toFixed(1)} ms (${total} segments in ${this.batches.length} batches)`);
0218 }
0219 }
0220
0221 /** The rgb of one track under the current color mode. */
0222 private trackColor(trackIndex: number, colorMode: string, solidColor: string, scratch: Color): Color {
0223 if (colorMode === 'solid') {
0224 return scratch.set(solidColor);
0225 }
0226 if (colorMode === 'momentum') {
0227 const px = this.trackParamNumber('px', trackIndex);
0228 const py = this.trackParamNumber('py', trackIndex);
0229 const pz = this.trackParamNumber('pz', trackIndex);
0230 if (px === null || py === null || pz === null || this.maxMomentum <= 0) {
0231 return scratch.setHex(NeonTrackColors.Gray);
0232 }
0233 const fraction = Math.min(1, Math.sqrt(px * px + py * py + pz * pz) / this.maxMomentum);
0234 // Blue (low) -> red (high), linear in |p|, same scale as the per-track painter
0235 return scratch.setHSL(0.66 * (1 - fraction), 1, 0.5);
0236 }
0237 const pdg = this.trackParamNumber('pdg', trackIndex);
0238 const charge = this.trackParamNumber('charge', trackIndex) ?? 0;
0239 return scratch.setHex(pidColorFor(pdg === null ? 0 : Math.floor(pdg), charge));
0240 }
0241
0242 /** Writes one track's color into every one of its segments in one batch. */
0243 private colorTrackSegments(batch: SegmentBatch, trackIndex: number, color: Color): void {
0244 const {trackIds, colors} = batch;
0245 for (let i = 0; i < batch.count; i++) {
0246 if (trackIds[i] !== trackIndex) continue;
0247 const base = i * 6;
0248 colors[base] = colors[base + 3] = color.r;
0249 colors[base + 1] = colors[base + 4] = color.g;
0250 colors[base + 2] = colors[base + 5] = color.b;
0251 }
0252 }
0253
0254 /** Pushes a batch's `colors` array into its geometry attribute. */
0255 private uploadColors(batch: SegmentBatch): void {
0256 const attribute = batch.geometry.attributes['instanceColorStart'];
0257 if (attribute && 'data' in attribute) {
0258 (attribute as unknown as { data: { array: Float32Array; needsUpdate: boolean } })
0259 .data.array.set(batch.colors);
0260 (attribute as unknown as { data: { needsUpdate: boolean } }).data.needsUpdate = true;
0261 } else {
0262 batch.geometry.setColors(batch.colors);
0263 }
0264 }
0265
0266 override onConfigChanged(): void {
0267 this.applyStyles();
0268 }
0269
0270 /** Recolors every segment and applies the shared width; drops any highlight. */
0271 private applyStyles(): void {
0272 const colorMode = this.config.value<string>('colorMode');
0273 const lineWidth = this.config.value<number>('lineWidth');
0274 const solidColor = this.config.value<string>('color');
0275 const piece = this.trajectoryPiece;
0276
0277 if (colorMode === 'momentum' && this.maxMomentum === 0) {
0278 for (let trackIndex = 0; trackIndex < piece.count; trackIndex++) {
0279 const px = this.trackParamNumber('px', trackIndex) ?? 0;
0280 const py = this.trackParamNumber('py', trackIndex) ?? 0;
0281 const pz = this.trackParamNumber('pz', trackIndex) ?? 0;
0282 this.maxMomentum = Math.max(this.maxMomentum, Math.sqrt(px * px + py * py + pz * pz));
0283 }
0284 }
0285
0286 // Per-track colors once, then per-segment fill through the sorted order
0287 const scratch = new Color();
0288 const trackRgb = new Float32Array(piece.count * 3);
0289 for (let trackIndex = 0; trackIndex < piece.count; trackIndex++) {
0290 const color = this.trackColor(trackIndex, colorMode, solidColor, scratch);
0291 trackRgb[trackIndex * 3] = color.r;
0292 trackRgb[trackIndex * 3 + 1] = color.g;
0293 trackRgb[trackIndex * 3 + 2] = color.b;
0294 }
0295
0296 for (const batch of this.batches) {
0297 for (let i = 0; i < batch.count; i++) {
0298 const track = batch.trackIds[i] * 3;
0299 const base = i * 6;
0300 batch.colors[base] = batch.colors[base + 3] = trackRgb[track];
0301 batch.colors[base + 1] = batch.colors[base + 4] = trackRgb[track + 1];
0302 batch.colors[base + 2] = batch.colors[base + 5] = trackRgb[track + 2];
0303 }
0304 this.uploadColors(batch);
0305 batch.material.linewidth = lineWidth;
0306 batch.material.needsUpdate = true;
0307 }
0308 // A restyle repaints highlighted tracks in their normal color, same as
0309 // the per-track painter.
0310 }
0311
0312 /**
0313 * Draws one track in the highlight color. Highlights are independent:
0314 * other highlighted tracks stay highlighted (the selection and the
0315 * hovered track show together).
0316 */
0317 override highlightEntity(entityIndex: number): void {
0318 const scratch = new Color().setHex(this.trackColorHighlight);
0319 for (const batch of this.batches) {
0320 this.colorTrackSegments(batch, entityIndex, scratch);
0321 this.uploadColors(batch);
0322 }
0323 }
0324
0325 override unhighlightEntity(entityIndex: number): void {
0326 const scratch = new Color();
0327 const color = this.trackColor(
0328 entityIndex,
0329 this.config.value<string>('colorMode'),
0330 this.config.value<string>('color'),
0331 scratch,
0332 );
0333 for (const batch of this.batches) {
0334 this.colorTrackSegments(batch, entityIndex, color);
0335 this.uploadColors(batch);
0336 }
0337 }
0338
0339 /**
0340 * Time cut: segments are pre-sorted by start time, so painting at time t is
0341 * a binary search + one instanceCount write per batch — the same segment
0342 * granularity the per-track painter reveals with (a segment shows once its
0343 * start point's time has passed).
0344 */
0345 public override paint(time: number | null): void {
0346 for (const batch of this.batches) {
0347 if (time === null || this.timeIndex < 0) {
0348 batch.geometry.instanceCount = batch.count;
0349 continue;
0350 }
0351 // Upper bound: first index with startTime > time
0352 let low = 0, high = batch.count;
0353 while (low < high) {
0354 const mid = (low + high) >> 1;
0355 if (batch.startTimes[mid] <= time) low = mid + 1;
0356 else high = mid;
0357 }
0358 batch.geometry.instanceCount = low;
0359 }
0360 }
0361
0362 public override dispose(): void {
0363 for (const batch of this.batches) {
0364 batch.geometry.dispose();
0365 batch.material.dispose();
0366 this.parentNode?.remove(batch.object);
0367 }
0368 this.batches = [];
0369 super.dispose();
0370 }
0371 }