t phi = (90.0 - latitude) * (Math.PI / 180.0);
const theta = (longitude + 180.0) * (Math.PI / 180.0);
const x = -sphereRadius * Math.sin(phi) * Math.cos(theta);
const y = sphereRadius * Math.cos(phi);
const z = sphereRadius * Math.sin(phi) * Math.sin(theta);
return [x, y, z];
}
#### 3. Fixed-Buffer Particle System
The core optimization is a circular buffer that reuses memory slots. We maintain a `Float32Array` for positions and another for particle attributes like age or lifetime.
```typescript
import * as THREE from 'three';
interface ParticleConfig {
maxParticles: number;
sphereRadius: number;
}
export class LiveParticlePool {
private positions: Float32Array;
private ages: Float32Array;
private writeIndex: number;
private geometry: THREE.BufferGeometry;
private maxCount: number;
constructor(config: ParticleConfig) {
this.maxCount = config.maxParticles;
this.writeIndex = 0;
// Pre-allocate buffers
this.positions = new Float32Array(this.maxCount * 3);
this.ages = new Float32Array(this.maxCount);
// Initialize geometry with static buffers
this.geometry = new THREE.BufferGeometry();
this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
this.geometry.setAttribute('age', new THREE.BufferAttribute(this.ages, 1));
// Mark as dynamic to hint GPU optimization
this.geometry.attributes.position.setUsage(THREE.DynamicDrawUsage);
this.geometry.attributes.age.setUsage(THREE.DynamicDrawUsage);
}
public injectEvent(lat: number, lng: number): void {
const [x, y, z] = mapGeographicToCartesian(lat, lng, 5.05);
// Write to current slot
const offset = this.writeIndex * 3;
this.positions[offset] = x;
this.positions[offset + 1] = y;
this.positions[offset + 2] = z;
// Reset age for new particle
this.ages[this.writeIndex] = 1.0;
// Advance circular buffer
this.writeIndex = (this.writeIndex + 1) % this.maxCount;
// Flag attributes for GPU update
this.geometry.attributes.position.needsUpdate = true;
this.geometry.attributes.age.needsUpdate = true;
}
public getGeometry(): THREE.BufferGeometry {
return this.geometry;
}
}
4. Custom Shader Material
Standard materials cannot handle per-particle fading efficiently. A custom ShaderMaterial allows us to scale point size and opacity based on the age attribute.
const particleVertexShader = `
attribute float age;
varying float vAge;
void main() {
vAge = age;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
// Scale point size based on age; particles shrink as they age
gl_PointSize = max(4.0, 12.0 * age);
gl_Position = projectionMatrix * mvPosition;
}
`;
const particleFragmentShader = `
varying float vAge;
void main() {
// Calculate distance from point center for circular shape
vec2 center = gl_PointCoord - vec2(0.5);
float dist = length(center);
// Discard pixels outside the circle
if (dist > 0.5) discard;
// Create radial intensity gradient
float intensity = 1.0 - smoothstep(0.0, 0.5, dist);
// Color mix: bright cyan when new, fading to dim blue
vec3 freshColor = vec3(0.2, 1.0, 0.8);
vec3 oldColor = vec3(0.1, 0.4, 0.6);
vec3 color = mix(freshColor, oldColor, 1.0 - vAge);
// Alpha combines intensity and age for smooth fade-out
float alpha = intensity * vAge;
gl_FragColor = vec4(color, alpha);
}
`;
5. Integration and Rendering
Combine the pool with the renderer. Ensure lazy initialization to preserve Largest Contentful Paint (LCP).
import * as THREE from 'three';
export class GlobeVisualizer {
private renderer: THREE.WebGLRenderer;
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private particlePool: LiveParticlePool;
private points: THREE.Points;
private animationId: number | null = null;
constructor(container: HTMLElement) {
this.initRenderer(container);
this.initScene();
this.initParticles();
this.initControls();
}
private initRenderer(container: HTMLElement): void {
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
// Cap pixel ratio to prevent excessive fill rate on retina displays
const dpr = Math.min(window.devicePixelRatio, 2);
this.renderer.setPixelRatio(dpr);
this.renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(this.renderer.domElement);
}
private initScene(): void {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
this.camera.position.z = 12;
}
private initParticles(): void {
this.particlePool = new LiveParticlePool({ maxParticles: 1000, sphereRadius: 5.05 });
const material = new THREE.ShaderMaterial({
vertexShader: particleVertexShader,
fragmentShader: particleFragmentShader,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
this.points = new THREE.Points(this.particlePool.getGeometry(), material);
this.scene.add(this.points);
}
public handleSSEMessage(data: { lat: number; lng: number }): void {
this.particlePool.injectEvent(data.lat, data.lng);
}
private animate(): void {
this.animationId = requestAnimationFrame(() => this.animate());
// Decay ages in buffer
const ages = this.particlePool.getGeometry().attributes.age.array as Float32Array;
for (let i = 0; i < ages.length; i++) {
ages[i] = Math.max(0, ages[i] - 0.015);
}
this.particlePool.getGeometry().attributes.age.needsUpdate = true;
// Slow rotation
this.points.rotation.y += 0.001;
this.renderer.render(this.scene, this.camera);
}
public start(): void {
this.animate();
}
}
Pitfall Guide
-
Geometry Recreation per Event
- Explanation: Calling
new THREE.BufferGeometry() or resizing attributes inside the event handler causes massive GC pressure.
- Fix: Allocate buffers once. Update values in the existing
Float32Array and set needsUpdate = true.
-
Uncapped Pixel Ratio
- Explanation: Using
window.devicePixelRatio directly on high-DPI screens can result in the GPU rendering 3x or 4x the pixels, causing thermal throttling and frame drops.
- Fix: Cap the pixel ratio:
Math.min(window.devicePixelRatio, 2).
-
Blocking Main Thread with Aggregation
- Explanation: Performing heavy data aggregation or coordinate math in the browser can block the render loop.
- Fix: Offload aggregation to a Cloudflare Worker or use a Web Worker for coordinate conversion if processing large batches.
-
Shader Discard Performance
- Explanation: While
discard in the fragment shader is standard for circular points, excessive use can impact performance on older mobile GPUs due to early-z rejection issues.
- Fix: Ensure
depthWrite is disabled for transparent particles and consider pre-calculating bounds if performance degrades on specific devices.
-
Coordinate System Mismatch
- Explanation: Three.js uses a Y-up coordinate system, while geographic data often assumes Z-up or different axis orientations.
- Fix: Rotate the globe mesh or adjust the conversion math to align the North Pole with the positive Y-axis.
-
SSE Reconnection Storms
- Explanation: Network flakiness can cause the SSE connection to drop and reconnect rapidly, overwhelming the server.
- Fix: Implement exponential backoff in the client-side SSE handler and use the
retry field in SSE events.
-
Buffer Overflow Logic Errors
- Explanation: Incorrect modulo arithmetic when wrapping the write index can cause data corruption or out-of-bounds writes.
- Fix: Use
(index + 1) % maxCount and ensure the buffer size is sufficient for the expected event rate.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Unidirectional Live Data | Server-Sent Events (SSE) | Simpler implementation, automatic reconnection, lower overhead than WebSockets. | Low (CF Workers pricing) |
| Bidirectional Interaction | WebSockets | Required if client needs to send control commands back to server. | Medium (Requires persistent connections) |
| High-Throughput Events | Edge Aggregation + SSE | Reduces message count and connection load; batches improve client processing efficiency. | Low (Queue processing cost) |
| Mobile-First Audience | Fixed Buffer Pool | Essential for maintaining 60 FPS and preventing GC spikes on resource-constrained devices. | None (Code optimization) |
| Complex Visuals | Custom GLSL Shaders | Standard materials cannot achieve per-particle fading and scaling efficiently. | None (Shader development time) |
Configuration Template
Use this template to configure the visualization parameters and SSE connection.
// config/globe-config.ts
export interface GlobeConfig {
maxParticles: number;
sphereRadius: number;
decayRate: number;
rotationSpeed: number;
sseEndpoint: string;
pixelRatioCap: number;
}
export const defaultConfig: GlobeConfig = {
maxParticles: 1000,
sphereRadius: 5.05,
decayRate: 0.015,
rotationSpeed: 0.001,
sseEndpoint: '/api/live-traffic',
pixelRatioCap: 2,
};
Quick Start Guide
- Setup Edge Worker: Create a Cloudflare Worker that accepts tracking events, pushes them to a Queue, and a consumer that aggregates events every 2 seconds and streams them via SSE.
- Initialize Renderer: Create a
WebGLRenderer with capped pixel ratio and attach it to a container element. Use lazy loading to avoid blocking page load.
- Create Particle Pool: Instantiate
LiveParticlePool with a fixed buffer size. Create a ShaderMaterial using the provided vertex and fragment shaders.
- Connect SSE: Establish an SSE connection to the edge endpoint. On message receipt, parse the batch and call
particlePool.injectEvent() for each coordinate.
- Start Animation Loop: Run
requestAnimationFrame to decay particle ages, rotate the globe, and render the scene. Ensure age decay matches the visual fade duration.