← Back to Blog

Starfield Depth Effects: Adding Subtle Immersion to a 3D Universe

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:

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:

  1. LLM suggests feature: "Consider adding distance-based brightness falloff to create depth perception"
  2. Human approves concept: "Sounds good, let's try it"
  3. LLM implements with sliders: Adds the effect with adjustable intensity (0-100%)
  4. Human tunes via sliders: Tests different values, provides feedback like "too strong at 50%, try lower"
  5. 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:

ValueEffectVerdict
0%No dimmingFlat, no depth
100%Severe dimmingToo obvious
75%Noticeable dimmingAlmost 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:

ValueEffectVerdict
0%Static backgroundNo depth
100%Obvious movementDistracting
75%Noticeable shiftAlmost
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:

EffectDefaultPurpose
Depth Brightness50%Distance → dimming
Depth Desaturation30%Distance → color fade
Parallax Intensity50%Camera motion → background shift
Star Glow15%Stellar luminosity halo
Star Flare35%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:

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:

MetricBeforeAfterChange
Frame time (60 FPS target)12ms14ms+2ms
Draw calls35+2
GPU memory48MB52MB+4MB
Shader compile time80ms95ms+15ms

The overhead is minimal because:

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 webgl depth effects star glow lens flare parallax shader visual effects game development eve frontier vibe coding llm development