Real-Time Data at Scale

For BFCM 2023, Shopify's real-time purchase visualization needed to handle a massive increase in simultaneous arcs representing orders. The previous approach of giving each arc its own mesh did not scale — rendering over 1000 arcs at once could cripple mobile devices. The solution was GPU instancing.

Instancing draws multiple copies of the same base mesh in a single draw call. Each instance carries unique properties through instanced buffer attributes, while sharing geometry and material data. For arcs, this means the Bézier curve calculation moves from the CPU to the vertex shader.

Each arc is defined by four 3D control points:

  • P0: start position
  • P1: 25% of the way between start and end
  • P2: 75% of the way between start and end
  • P3: end position

Arc height is adjusted by moving P1 and P2 away from the surface. The mesh is a strip of triangles following the curve, with UVs where v runs from 0 at the start to 1 at the end. To keep arcs visible from any angle, the vertex shader takes the cross product of the curve's tangent with the camera direction and offsets vertices accordingly.

Animation uses a normalized age from 0 to 2: 0 marks the start, 1 means arrival at the destination, and 2 indicates the full trail effect has played out. A noise function driven by UV and age creates the dissolve effect. Each arc has a startTime attribute, while a shared uTime uniform advances all arcs each frame.

With instancing, the per-arc data (P0 through P3 plus start time) is interleaved into one buffer. When a new order arrives, the code loops through instances until it finds one with age > 2, then uploads updated control points and start time for only that arc. Benchmarking on an M1 laptop showed up to one million concurrent arcs rendered in a single draw call.

Particles and Celebrations

City markers use instanced particles with gl.POINTS rather than surface-aligned meshes. Billboarded points produce a nicer glow near the horizon, which becomes visible when comparing the two approaches side by side.

For merchants' first sales, fireworks celebrate above the city. A base mesh is generated from Three.js's IcosahedronGeometry, with triangle strips connecting the center to each vertex. Gravity pulls vertices down along trails as the burst expands. UVs are structured like the arcs, and the same noise technique makes bursts dissipate over time, with a delayFactor giving trails a moment before fading.

Bloom postprocessing — a first for the globe — significantly increases visual impact. Launch trails are included in the same mesh using an isBurstTrail geometry attribute to differentiate setup logic in the shader. An additional long triangle strip extends from the origin to the burst center. Linear animation felt slow, so cubic easing speeds things up. Shader uniforms for launch trail height, burst size, and rotation offsets allow grouping fireworks in patterns — all still within one draw call, with per-firework start times driving independent animation.

Camera Movement Without Flipping

Searching for a city requires a smooth camera path that frames the destination on the horizon. The straightforward approach uses spherical interpolation. Convert start and end camera positions to spherical coordinates with radius, phi, and theta (zoom, latitude, longitude), then interpolate. The camera's lookAt function keeps the destination in view, and offsetting phi slightly downward tilts the frame toward the horizon.

Trouble arises in the southern hemisphere. The lookAt function relies on the camera's up property — a reference direction, not the actual up vector. For destinations like Sydney, the angle between the default up [0, 1, 0] and the camera's real upward direction exceeds 90 degrees, causing the view to flip. The fix is to update up each frame using the current phi and theta values, keeping camera orientation stable for any city on Earth.

Pins and Airplanes

To encourage city exploration, small pins appear on the globe and can be tapped for navigation. The entrance animation uses react-spring with custom easing — the difference from an earlier linear version demonstrates the polish good easing provides.

Airplanes travel orbits between two random cities. A simple CPU approach would parent each airplane to a pivot object, offset it, and rotate the pivot. For GPU animation in a single draw call, the same logic is moved into a vertex shader using trigonometry. The airplane material is a MeshBasicMaterial, and its vertex shader is modified via onBeforeCompile. Each instance's transformation matrix represents the lookAt rotation, and a currentTime uniform advances the animation each frame.

The globe uses about a dozen instanced airplanes, well below what the GPU could handle. The single non-instanced exception is the "Shopify Airplane," a special flight that interpolates across 6 orbits. Banking during orbit transitions and sinusoidal swaying in both axes make the motion feel natural. At peak BFCM traffic, with thousands of orders animating each second, it became a memorable centerpiece of the experience.

Hermite Curves for Loopy Arcs

One visual flourish we wanted was "loopy" flight arcs — paths that curl and spin rather than following a clean great-circle route. These add whimsy to the visualization, even if they'd be a terrible way to route actual logistics.

Rather than using Bézier splines, we chose cubic Hermite splines for these animations. Hermite splines are common in 3D animation tooling and give finer control over motion. Each spline segment is defined by a start point, an end point, and a tangent (slope) at each end. The X axis represents time; the Y axis represents the animated value.

To sample a value at an arbitrary time:

  1. Find the surrounding keyframes. For a curve with few keyframes, a linear search suffices; more sophisticated data structures work for large sets. For time = 3 seconds, locate the surrounding keyframe pairs.
  2. Interpolate between them using the Hermite interpolation function.

Animating multiple properties requires separate curves — one for X position, one for Y, and so on. Combining these curves yields the loopy arc trajectories we wanted. The animation exists in a local coordinate space; we use the Matrix4 version of lookAt to build a rotation matrix that aligns the arc's X axis between the two cities while keeping Y pointing outward from the globe.

Instancing the Arcs

Loopy arcs share the same geometry as regular arcs — triangle layout, billboarding, and UV noise fading are all identical. The difference lies in the per-arc attributes we upload to the GPU. Each keyframe is 4 floats; summing all required data gave 94 floats (376 bytes) per arc, which trips a WebGL limit:

Stride is over the maximum stride allowed by WebGL

The maximum stride is 255 bytes. We considered packing the data into a texture (vertex texture animation) to get around this, but inspecting the curves revealed many zero-valued tangents. Many nonzero floats were duplicates or negations of others. After identifying only the unique attributes, we needed just 17 floats per arc — well below the stride limit, with the rest hardcoded or computed in the shader.

In hindsight, Bézier curves combined with circle equations would have been simpler and more performant. But exploring Hermite curves gives more room for bespoke animation control in future builds.

Risograph-Inspired Globe Material

Early style experiments landed on a hand-painted, risograph-print look. A defining feature of that aesthetic is its pervasive noise — fields of small dots with varying sizes.

An initial attempt at screen-space noise using react-postprocessing didn't look right. Applying noise directly to the globe's water, land, and atmosphere materials is tricky: it must wrap a sphere without visible seams, stretching, or polar distortion. We tried Perlin, Simplex, and Worley noise without satisfaction until discovering psrdnoise, a Simplex noise variant that tiles in 2D and 3D and supports an animated "flow noise" technique in 3D. Published in 2022, it's a recent addition to the noise toolbox.

3D psrdnoise applies to a sphere with zero modification and produces seamless, undistorted results. The approach: take vertex positions from a standard THREE.SphereGeometry, feed them through the noise function (optionally animating via a time value), and layer multiple calls with different scales for detail. Adding repeated noise layers via fractal summation creates the flowing effect.

However, 3D psrdnoise is computationally expensive, especially when called multiple times per fragment. We switched to the cheaper 2D variant, which required fixing seams and distortion caused by the icosphere's default UV mapping. A custom UV layout using six round planes eliminated stretching and polar distortion. Calling 2D psrdnoise multiple times then masked the remaining seams entirely. The final result slightly favors 3D noise visually, but the 2D version is the pragmatic compromise.

Risograph style
Depiction of Risograph style artwork used as inspiration for Globe.

Shader Slimming

Three.js's standard shader is physically based (PBR) and built for realism — but that realism is costly on mobile. Using the onBeforeCompile hook, we stripped out every part that had no visible effect on appearance, tested by trial and error. The final shader is roughly a hundred lines versus the standard's thousands, and most calculations were moved from fragment to vertex stage. The visual tradeoff is barely perceptible.

Starfield Shader

The distant stars also leaned on psrdnoise. A convincing starry sky needs only a few GLSL lines:

  1. Start with an icosphere matching the globe's UVs.
  2. Feed those UVs into 2D psrdnoise without a time input to keep the stars static.
  3. Scale and stretch the UVs to shape the starfield layout.
  4. Raise the noise to a power via the pow function; a large exponent leaves only the brightest peaks visible as stars.
  5. Multiply the resulting color by an intensity factor to feed bloom.

Reflections on the Build

The BFCM 2023 Globe pushed against instancing and WebGL limits in several directions: shrinking per-arc attributes from 376 bytes to a legal 17 floats, swapping expensive 3D noise for a clever 2D setup, and reducing a PBR shader to a lean fragment of its former self. Each constraint led to a workaround that shaped the final experience — and those lessons are carrying into the next iteration of the project.