What if your ship's jump range indicator looked like a soap bubble floating in space—rainbow colors shifting as you rotate the camera, film-like bands flowing across its surface? That's exactly what we built for EVE Frontier Map's reachability visualization. Instead of a boring solid sphere, we created a physically-inspired thin-film interference shader that simulates the real optics of soap bubbles and oil slicks.
The Problem: Visualizing Distance in 3D Space
When a player selects a star system and wants to see how far their ship can jump, we need to display a sphere showing all reachable destinations. The naive approach—a semi-transparent colored sphere—works but looks flat and uninteresting. We wanted something that:
- Immediately communicates "this is your range"
- Looks beautiful enough to screenshot
- Respects the user's chosen theme color (orange, blue, green, etc.)
- Animates smoothly without being distracting
The answer came from physics: thin-film interference.
The Physics: How Soap Bubbles Get Their Colors
When light hits a thin transparent film (like a soap bubble), some light reflects off the top surface while some passes through and reflects off the bottom. These two reflected beams interfere with each other. Depending on the film's thickness and your viewing angle, certain wavelengths (colors) are amplified while others cancel out.
Constructive interference occurs when: 2 × n × d × cos(θ) = m × λ
Where n = refractive index, d = film thickness, θ = refracted angle, m = interference order, λ = wavelength. This equation determines which colors you see at different viewing angles.
This creates the characteristic rainbow swirls of soap bubbles—colors that shift as the bubble moves or as you change your viewpoint. We implemented this in GLSL shaders running on the GPU.
The Implementation: GLSL Shaders in Three.js
Our implementation lives in JumpRangeBubble.ts and consists of three layered elements:
1. The Main Interference Shell
The vertex shader handles position wobble (for that organic bubble feel) and calculates the Fresnel term—how much the surface faces toward or away from the camera:
// Wobble displacement along normal for organic bubble feel
float wobble1 = sin(position.x * 3.0 + uTime * uWobbleSpeed) * 0.4;
float wobble2 = sin(position.y * 4.0 + uTime * uWobbleSpeed * 1.3) * 0.3;
vec3 displacedPos = position + normal * wobbleDisplacement;
// Fresnel calculation - edges vs center visibility
float NdotV = dot(vWorldNormal, vViewDir);
vFresnel = pow(1.0 - abs(NdotV), 1.8);
2. Wavelength to RGB Conversion
We convert interference wavelengths (380-780nm) to visible colors using overlapping Gaussian curves that approximate the human eye's spectral response:
vec3 wavelengthToRGB(float wavelength) {
float w = (wavelength - 380.0) / 400.0; // Normalize to 0-1
// Red peaks around 650nm
float r = exp(-pow((w - 0.75) * 3.5, 2.0));
// Green peaks around 550nm
float g = exp(-pow((w - 0.45) * 3.0, 2.0));
// Blue peaks around 450nm
float b = exp(-pow((w - 0.15) * 3.0, 2.0));
return vec3(r, g, b);
}
3. Theme Color Biasing
Pure physics would give us rainbow colors with no connection to the user's chosen theme. We solve this by biasing the interference output toward the theme color while preserving the iridescent variation:
vec3 biasTowardTheme(vec3 color, float themeHue, float bias) {
vec3 themeColor = hsl2rgb(themeHue, 0.8, 0.55);
return mix(color, color * themeColor * 2.0, bias);
}
With uColorBias at 0.45, we get recognizable theme colors (orange stays orange-ish) but with rainbow shimmer at the edges.
Flowing Patterns: Animated Thickness Variation
Real soap bubbles have constantly-shifting thickness as the film flows under gravity and surface tension. We simulate this with two noise functions blended together:
| Pattern | Purpose | Character |
|---|---|---|
flowNoise() |
Large-scale undulations | Smooth, slow waves |
spiralFlow() |
Spiraling bands | Wraps around sphere poles |
By animating the time uniform (uTime) at 0.525× real-time, the patterns flow smoothly without being dizzying. The gentle rotation (group.rotation.y = rel * 0.15) adds to the dynamic feel.
Layered Rendering for Depth
A single shell looks flat. We add two more layers for depth perception:
- Inner glow (98% radius, BackSide rendering): Subtle additive glow visible through the main shell
- Outer halo (102% radius, FrontSide): Very faint atmospheric edge
Each layer has progressively lower opacity (0.24 → 0.0375 → 0.03) to prevent the bubble from obscuring the stars inside it.
Performance Considerations
The shader runs per-fragment on a 96×64 tessellation sphere (6,144 triangles). For a typical 1080p viewport, this means:
- ~200K fragment shader invocations when bubble fills screen
- All math is GPU-native (sin, cos, exp, smoothstep)
- No texture lookups—purely procedural
- Runs at 60 FPS even on integrated graphics
During development, we exposed all uniforms to window.__efBubble* globals for real-time tuning in browser DevTools. This let us dial in thickness ranges (320-580nm) and flow speeds without rebuilding.
The Result
The jump range bubble now looks like a delicate soap film floating in space. When you zoom in close, you can see the rainbow bands flowing across its surface. When you rotate the camera, colors shift—just like a real bubble. And it all respects your theme: orange pilots get warm iridescence, blue pilots get cool tones.
This is the kind of detail that doesn't affect gameplay but makes EVE Frontier Map feel polished and alive. Sometimes the best features are the ones you don't consciously notice—they just make everything feel right.
Related Posts
- Three.js Rendering: Building a 3D Starfield for 200,000 Systems - The foundation for all our WebGL visualizations
- Performance Optimization Journey: From 8-Second Loads to 800ms - How we keep shaders like this running at 60 FPS
- Cinematic Mode: Immersive Exploration of New Eden - Another visual polish feature for immersive exploration
- Solar System View: A Three-Day Journey - More Three.js rendering challenges in EVE Frontier Map