When you're staring at 8,000+ stars in a 3D map, something can feel... flat. The stars are all there, correctly positioned in space, but the sense of depth—the feeling that you're looking into an infinite universe—is missing. This is the story of how we added five interlocking visual effects to make EF-Map's starfield feel alive, and why the best effects are the ones you don't consciously notice.
The Subtle-by-Design Philosophy
Most users won't notice these effects are there. That's intentional. The goal isn't to create flashy visuals that scream "look at this effect!"—it's to make the map just feel better without users knowing why. When someone says "this looks nice" but can't point to a specific reason, we've succeeded.
The Problem: A Universe That Felt Like a Diagram
Before this update, EF-Map rendered stars as simple white points in 3D space. The positions were accurate, the routing algorithms worked perfectly, but every star was just... white. No color variation, no visual hierarchy. It felt like looking at a scientific diagram rather than a living universe.
The issues were subtle but cumulative:
- No depth cues: Stars 5 units away looked exactly like stars 500 units away
- No atmospheric effects: No glow, no light scatter, no sense of stellar luminosity
- Static background: The starfield backdrop didn't respond to camera movement
- Uniform appearance: Every star rendered identically regardless of viewing angle
The technical foundation was solid, but the emotional impact was missing. We needed to add depth without compromising the map's utility as a navigation tool.
The LLM Collaboration Approach
This feature was developed through an iterative dialogue between a human operator (non-coder) and an LLM assistant. The workflow looked like this:
- LLM suggests feature: "Consider adding distance-based brightness falloff to create depth perception"
- Human approves concept: "Sounds good, let's try it"
- LLM implements with sliders: Adds the effect with adjustable intensity (0-100%)
- Human tunes via sliders: Tests different values, provides feedback like "too strong at 50%, try lower"
- Set defaults, keep sliders: Once optimal values found, set them as defaults but keep the sliders so users can customize
This approach has a key advantage: the human can feel whether an effect works without understanding the underlying shader math. The LLM handles implementation; the human handles aesthetic judgment. And crucially, users retain full control—if someone finds parallax nauseating or prefers the old flat look, they can disable any effect entirely.
Effect 1: Depth Brightness (Distance-Based Dimming)
The Concept
Stars further from the camera should appear slightly dimmer. Not dramatically—we're not simulating realistic light falloff over lightyears—but enough to create a subconscious depth cue.
The Implementation
We calculate the distance from each star to the camera, normalize it against an "effect range" (20 units), and apply a brightness multiplier:
// In the shader
float distanceToCamera = length(cameraPosition - worldPosition);
float normalizedDist = clamp(distanceToCamera / effectRange, 0.0, 1.0);
float brightnessMod = 1.0 - (normalizedDist * depthBrightnessIntensity);
gl_FragColor.rgb *= brightnessMod;
The Tuning Process
Initial tests with 100% intensity were too aggressive—distant stars became nearly invisible. We settled on a balanced default after testing:
| Value | Effect | Verdict |
|---|---|---|
| 0% | No dimming | Flat, no depth |
| 100% | Severe dimming | Too obvious |
| 75% | Noticeable dimming | Almost right |
| 50% | Subtle dimming | ✓ Perfect |
At 50%, the effect creates perceptible depth while remaining visually coherent—distant stars are noticeably dimmer but still clearly visible.
Effect 2: Depth Desaturation (Distance-Based Color Fade)
The Concept
In atmospheric conditions, distant objects appear more washed out due to light scattering. We can simulate this by reducing color saturation for distant stars, making them appear slightly grayer.
The Implementation
We convert RGB to a luminance value and blend between the original color and grayscale based on distance:
// Calculate grayscale luminance
float luminance = dot(color.rgb, vec3(0.299, 0.587, 0.114));
vec3 grayscale = vec3(luminance);
// Blend based on distance
float desatAmount = normalizedDist * depthDesaturationIntensity;
color.rgb = mix(color.rgb, grayscale, desatAmount);
The Tuning Process
Desaturation is even more subtle than brightness. At high values, the effect became too obvious—stars looked washed out. The sweet spot was very low:
Final Default: 30% Desaturation
At this level, distant stars have a slightly muted quality that suggests atmospheric depth. Users don't consciously notice the desaturation—they just perceive the distant region as "further away."
Effect 3: Parallax Background Layers
The Concept
When you move through a 3D environment, distant objects should move more slowly than nearby objects. This is parallax—and it's one of the strongest depth cues available. We added subtle parallax movement to the background starfield.
The Implementation
The background consists of three very faint star layers at different depths. These layers are offset based on camera position, with the offset magnitude controlled by a parallax intensity slider:
// Parallax offset calculation
const parallaxOffset = new THREE.Vector3(
cameraPosition.x * parallaxIntensity * 0.1,
cameraPosition.y * parallaxIntensity * 0.1,
0
);
backgroundLayer.position.copy(basePosition).add(parallaxOffset);
The Tuning Process
Parallax was trickier because too much movement felt nauseating. The background should shift subtly, not swim around:
| Value | Effect | Verdict |
|---|---|---|
| 0% | Static background | No depth |
| 100% | Obvious movement | Distracting |
| 75% | Noticeable shift | Almost |
| 50% | Subtle shift | ✓ Natural |
At 50% intensity, the background responds to camera movement just enough to feel three-dimensional without calling attention to itself.
Effect 4: Star Glow (Radial Luminosity)
The Concept
Real stars don't have hard edges—they glow. Adding a soft radial glow around each star creates the impression of stellar luminosity. Critically, the glow is only rendered on nearby stars—the purpose is to make close stars feel closer by giving them a luminous presence that distant stars lack.
The Implementation
We create a procedural glow texture using a 2D canvas with a radial gradient:
function createGlowTexture() {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d');
// Warm-tinted radial gradient
const gradient = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
gradient.addColorStop(0, 'rgba(255, 248, 240, 0.8)'); // Warm white core
gradient.addColorStop(0.2, 'rgba(255, 245, 235, 0.4)'); // Soft falloff
gradient.addColorStop(0.5, 'rgba(255, 240, 220, 0.15)'); // Gentle fade
gradient.addColorStop(1, 'rgba(255, 235, 210, 0)'); // Transparent edge
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 128, 128);
return new THREE.CanvasTexture(canvas);
}
The glow is rendered as a separate point sprite layer with additive blending, positioned at each star's location. Visibility fades out as stars get further away:
// Glow visibility decreases with distance (only visible on close stars)
float glowVisibility = 1.0 - smoothstep(0.0, effectRange, distanceToCamera);
float finalGlowAlpha = baseGlow * glowIntensity * glowVisibility;
The Tuning Process
Glow intensity was balanced against performance (each glow is an additional draw call) and visual impact:
Final Default: 15% Glow Intensity
At 15%, the glow creates a soft luminous halo around nearby stars, making them feel closer and more present. The effect "sells" the idea that these are massive fusion reactors, not just data points—and the lack of glow on distant stars reinforces the sense of depth.
Effect 5: Star Flare (Directional Light Scatter)
The Concept
Camera lenses produce characteristic flare patterns when pointed toward bright light sources. Adding a subtle lens flare effect—particularly for stars viewed from certain angles—adds another layer of visual richness. The key insight: flares should vary in orientation per-star to avoid a uniform artificial look.
The Journey to the Bow-Tie Shape
This effect went through several iterations:
Iteration 1: Simple Cross Pattern
First attempt used a basic cross (+ shape). Problem: looked too artificial, like a video game HUD element.
Iteration 2: Four-Point Star
Added diagonal lines for a star pattern. Problem: too symmetrical, all stars looked identical.
Iteration 3: Straight Band
Simplified to a horizontal line through the star. Problem: looked like a glitch, not an optical effect.
Iteration 4: Bow-Tie Shape (Final)
The winning design: two opposing cones meeting at the star's center, creating a "bow-tie" or hourglass shape. This mimics real lens flare physics where light diffracts in opposing directions.
The Implementation Challenge: Soft Edges
Initial bow-tie textures had hard edges that looked like geometric shapes rather than light scatter. The solution was pixel-level alpha calculation:
function createFlareTexture() {
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(size, size);
const data = imageData.data;
const center = size / 2;
const spreadAngle = 0.12; // Narrow bow-tie
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = x - center;
const dy = y - center;
const dist = Math.sqrt(dx * dx + dy * dy);
const normalizedDist = dist / center;
// Skip pixels outside the circle
if (normalizedDist > 1) continue;
// Calculate angle from center
const angle = Math.atan2(dy, dx);
const normalizedAngle = Math.abs(angle) / Math.PI;
// Bow-tie: bright near horizontal axis (angle ≈ 0 or π)
const angleFromHorizontal = Math.min(
normalizedAngle,
1 - normalizedAngle
);
// Soft falloff from center axis
const angleFalloff = Math.exp(-angleFromHorizontal / spreadAngle);
// Radial falloff: stronger near center
const radialFalloff = 1 - Math.pow(normalizedDist, 0.5);
// Combine for final alpha
const alpha = angleFalloff * radialFalloff * 255;
const idx = (y * size + x) * 4;
data[idx] = 255; // R
data[idx + 1] = 255; // G
data[idx + 2] = 255; // B
data[idx + 3] = Math.min(255, alpha);
}
}
ctx.putImageData(imageData, 0, 0);
return new THREE.CanvasTexture(canvas);
}
Per-Star Random Rotation
With all flares pointing the same direction, the effect looked artificial. The solution: assign each star a random rotation angle stored as a vertex attribute:
// Generate random rotation per star (deterministic based on position)
const rotationArray = new Float32Array(starCount);
for (let i = 0; i < starCount; i++) {
rotationArray[i] = Math.random() * Math.PI * 2;
}
geometry.setAttribute('aRotation',
new THREE.BufferAttribute(rotationArray, 1));
// In fragment shader: rotate UV coordinates
float cosR = cos(vRotation);
float sinR = sin(vRotation);
vec2 centeredUV = gl_PointCoord - 0.5;
vec2 rotatedUV = vec2(
centeredUV.x * cosR - centeredUV.y * sinR,
centeredUV.x * sinR + centeredUV.y * cosR
) + 0.5;
vec4 flareColor = texture2D(flareTexture, rotatedUV);
Orientation Factor
One final refinement: flares are more visible when viewed from above or below the galactic plane (where you're looking "across" the star rather than "into" it):
// Orientation factor: stronger flare from top/bottom view
vec3 toStarNorm = normalize(worldPosition - cameraPosition);
float orientationFactor = 0.3 + 0.7 * abs(toStarNorm.y);
float finalFlareAlpha = flareAlpha * flareIntensity * orientationFactor;
The Tuning Process
Flare intensity was the most sensitive parameter. Too high, and the map looked like a J.J. Abrams movie. Too low, and the effect disappeared:
Final Default: 35% Flare Intensity
At 35%, the bow-tie flares are visible as subtle light scatter without overwhelming the star colors. The random rotation prevents any pattern recognition, making the effect feel organic.
The Combined Effect
Each effect is subtle in isolation. Combined, they create a cumulative depth perception that makes the starfield feel three-dimensional:
| Effect | Default | Purpose |
|---|---|---|
| Depth Brightness | 50% | Distance → dimming |
| Depth Desaturation | 30% | Distance → color fade |
| Parallax Intensity | 50% | Camera motion → background shift |
| Star Glow | 15% | Stellar luminosity halo |
| Star Flare | 35% | Lens flare with random rotation |
Why These Values?
The defaults strike a balance between visibility and subtlety. A 50% brightness slider creates a noticeable but not jarring depth gradient across the starfield. Combined effects reinforce each other—the cumulative result is stronger than any individual setting would suggest.
Technical Challenges Encountered
Challenge 1: Shader Token Limits
Adding multiple effects to an existing shader pushed us against WebGL's instruction limits on some mobile GPUs. Solution: combine calculations where possible and use smoothstep instead of branching logic.
Challenge 2: Canvas Texture Anti-Aliasing
Initial glow and flare textures had visible banding due to gradient quantization. Solution: use larger texture sizes (128×128 for glow, 256×256 for flare) and ensure proper filtering:
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false; // Prevent mipmap artifacts
Challenge 3: Geometry Attribute Updates
The per-star rotation attribute needed to be added to existing geometry without breaking the render pipeline. Key insight: ensure the attribute array length matches the position array exactly:
// Star count from existing positions
const starCount = positions.length / 3;
const rotationArray = new Float32Array(starCount);
// ... populate rotationArray
geometry.setAttribute('aRotation',
new THREE.BufferAttribute(rotationArray, 1));
Challenge 4: Effect Range Hardcoding
During development, "effect range" was a tunable slider (1-100 units). After testing, we found 20 units worked universally and removed the slider to reduce UI complexity. The hardcoded value is now embedded directly in the animation loop:
const effectRange = 20; // No longer configurable
The Human-LLM Iteration Cycle
This feature exemplifies the "vibe coding" workflow where a non-coder directs an LLM through natural language:
Example Dialogue (Flare Shape)
Human: "The flare is going straight through like a band. I want it to be a bow-tie shape emanating FROM the star, not going through it."
LLM: "I understand—you want triangular cones that meet at the star's center, spreading outward in opposite directions. Let me update the texture generation to use angular falloff from the horizontal axis..."
Human: "Better, but the edges are too hard. It looks geometric."
LLM: "Got it. I'll switch from canvas path drawing to pixel-level alpha calculation with exponential falloff. This will create soft gradient edges instead of hard triangles..."
Human: "Perfect. But all the flares point the same direction—they need random rotation."
LLM: "Adding a per-star rotation attribute with values from 0 to 2π, applied in the fragment shader via UV coordinate rotation..."
The Value of Sliders During Development
Every effect started with a slider (0-100%). This allowed rapid iteration:
- Human adjusts slider in real-time
- Immediate visual feedback
- No code changes required per adjustment
- Easy to find "too much" and "too little" boundaries
Importantly, we kept all the sliders in the final release. The defaults are set low enough that most users won't notice the effects—but anyone who finds them distracting (or wants to crank them up) has full control. User preferences persist in local storage, so each person gets their preferred experience.
Performance Impact
Adding five visual effects could have hurt performance. Here's the actual impact:
| Metric | Before | After | Change |
|---|---|---|---|
| Frame time (60 FPS target) | 12ms | 14ms | +2ms |
| Draw calls | 3 | 5 | +2 |
| GPU memory | 48MB | 52MB | +4MB |
| Shader compile time | 80ms | 95ms | +15ms |
The overhead is minimal because:
- Glow and flare use the same geometry as stars (no additional vertex data)
- Textures are small (128×128, 256×256) and generated once at startup
- Per-frame calculations are simple (distance, angle, smoothstep)
- Additive blending is GPU-efficient
What We Learned
1. Subtlety Compounds
Multiple effects combine into a perceptible improvement. Each slider adjustment seems minor in isolation, but the cumulative result creates genuine depth perception.
2. Sliders Enable Non-Coders
The human operator found optimal values through direct manipulation, not code review. This democratizes aesthetic tuning—you don't need to understand shaders to know what looks good.
3. Keep User Control
We could have hardcoded the defaults and removed the sliders entirely. Instead, we kept them—because not everyone wants the same experience. Some users might find parallax nauseating, or prefer a flat diagram-style view. By keeping sliders with sensible defaults, we get the best of both worlds: an improved out-of-box experience, with full customization for power users.
4. Texture Quality Matters at Scale
With 8,000+ stars, any texture artifact gets multiplied. The extra development time for pixel-level alpha calculation (vs. simple gradients) was worth the visual improvement.
5. Random Variation Prevents Pattern Recognition
Per-star rotation for flares was essential. Without it, the brain instantly recognizes "every star has the same flare angle" and the effect feels artificial. With random rotation, each star feels unique.
Conclusion: The Invisible Upgrade
If we've done our job correctly, most users will never notice these effects. They'll load the map, explore the universe, and feel like they're looking into a living cosmos rather than a database visualization. They won't think "nice lens flare" or "cool parallax"—they'll just feel that the map is immersive.
That's the goal of subtle-by-design: effects that improve the experience without demanding attention. The best visual polish is the kind you don't consciously see.
Try It Yourself
The depth effects are live on EF-Map. Pan around, zoom in and out, and try to spot the individual effects. If you can't immediately identify them, that means they're working exactly as intended.
Related Posts
- Three.js Rendering: Building a 3D Starfield - The foundation of our star rendering system—InstancedMesh, shaders, and the technical architecture that makes depth effects possible.
- Performance Optimization Journey - How we achieved 60fps rendering with 24,000+ star systems using spatial indexing, LOD, and GPU instancing.
- Vibe Coding: Large-Scale LLM Development - The methodology behind human-AI collaboration that shaped this project, including the iterative approach used for these visual effects.
- Project Journey: August to December 2025 - The complete story of building EF-Map from prototype to production, including the evolution of visual fidelity.