For the new Zenovay landing page I built a 3D globe that shows actual live visits from customer sites in close to real time. Dots glow and fade where visits happen.
For the new Zenovay landing page I built a 3D globe that shows actual live visits from customer sites in close to real time. Dots glow and fade where visits happen.
This post is a complete walkthrough of how it's built, in case you want to do something similar.
What you see vs what it actually is
What the visitor sees: a smooth spinning Earth with bright dots appearing in different countries every few seconds.
What it actually is:
- A
THREE.SphereGeometrywith an Earth texture - A
THREE.Pointsinstance updated in place as events arrive - A custom GLSL fragment shader for the glow effect
- Server Sent Events streaming batched data from Cloudflare Workers
- Lazy initialization to keep LCP fast
The data flow
Visitor on customer site
-> Tracking script POSTs event to Cloudflare Worker (sub 100ms)
-> Worker pushes to Queue
-> Consumer aggregates per region every 2s
-> SSE stream pushes to landing page
-> Three.js animates new dot
The points system (the tricky part)
Points need to appear, glow, and fade. Re creating geometry every event is too expensive. Instead, allocate a fixed buffer and reuse slots.
buildPointsSystem() {
this.maxPoints = 1000
this.points = new Float32Array(this.maxPoints * 3)
this.lifetimes = new Float32Array(this.maxPoints)
this.cursor = 0
this.pointsGeometry = new THREE.BufferGeometry()
this.pointsGeometry.setAttribute('position',
new THREE.BufferAttribute(this.points, 3))
this.pointsGeometry.setAttribute('lifetime',
new THREE.BufferAttribute(this.lifetimes, 1))
}
addPoint(lat, lng) {
const [x, y, z] = latLngToVec3(lat, lng, 5.05)
const i = this.cursor * 3
this.points[i] = x
this.points[i + 1] = y
this.points[i + 2] = z
this.lifetimes[this.cursor] = 1.0
this.cursor = (this.cursor + 1) % this.maxPoints
this.pointsGeometry.attributes.position.needsUpdate = true
}
The glow shader
// Vertex shader
attribute float lifetime;
varying float vLifetime;
void main() {
vLifetime = lifetime;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = 8.0 * lifetime;
gl_Position = projectionMatrix * mvPosition;
}
// Fragment shader
varying float vLifetime;
void main() {
vec2 center = gl_PointCoord - vec2(0.5);
float dist = length(center);
if (dist > 0.5) discard;
float intensity = 1.0 - smoothstep(0.0, 0.5, dist);
vec3 color = mix(vec3(0.3, 1.0, 0.7), vec3(0.5, 1.0, 0.9), vLifetime);
gl_FragColor = vec4(color, intensity * vLifetime);
}
Coordinates: lat/lng to 3D
function latLngToVec3(lat, lng, radius) {
const phi = (90 - lat) * (Math.PI / 180)
const theta = (lng + 180) * (Math.PI / 180)
return [
-radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta)
]
}
Performance notes
-
THREE.PointswithBufferGeometrykeeps GPU memory stable - Fixed size buffer avoids GC pressure
- Pixel ratio capped at 2 (no point rendering 3x on retina)
- Lazy initialization: globe loads after main content paint
- 60fps on iPhone 12, ~45fps on a 5 year old budget Android
See the final result on the landing page: zenovay.com
If you want the full source as a template, drop a comment and I'll open source it.
Valerio

Top comments (0)