DEV Community

Richard Fu
Richard Fu

Posted on • Originally published at richardfu.net on

Solving Starfield Perspective Distortion in 3D Space: A Three.js Case Study

When building a 3D space simulation with Three.js WebGPU, I encountered a challenging visual problem that many 3D developers face: perspective distortion of billboard sprites. Here’s how I identified the issue and implemented an elegant solution.

Read more: Solving Starfield Perspective Distortion in 3D Space: A Three.js Case Study

The Problem: Stretched Stars Behind the Camera

In my Earth simulation game, I implemented a starfield background using 15,000 instanced plane geometries positioned on a sphere around the camera. The stars looked perfect when viewed straight-on, but had severe distortion issues:

  • Stars behind the camera appeared as vertical lines (stretched 2-3x in height)
  • Brightness varied dramatically based on viewing angle
  • Shape inconsistency made the starfield look unrealistic

Understanding the Root Cause

The distortion occurred due to perspective projection foreshortening :


// Original problematic approach
const radius = 40 + Math.random() * 20; // Stars too close to camera
const position = new THREE.Vector3(
  radius * Math.sin(phi) * Math.cos(theta),
  radius * Math.sin(phi) * Math.sin(theta), 
  radius * Math.cos(phi)
);

// Static plane orientation - doesn't face camera
const quaternion = new THREE.Quaternion(); // Identity rotation
matrix.compose(position, quaternion, scale);

Enter fullscreen mode Exit fullscreen mode

Why this failed:

  1. Close proximity (40-60 units) amplified perspective effects
  2. Fixed orientation meant planes viewed at oblique angles appeared stretched
  3. Perspective projection caused extreme foreshortening near camera edges

Attempted Solutions

Solution 1: Increase Distance


// Attempt 1: Move stars further away
const radius = 200 + Math.random() * 100;

Enter fullscreen mode Exit fullscreen mode

Result: Stars disappeared beyond the camera’s far clipping plane (100 units).

Solution 2: Shader-Based Billboarding


// Attempt 2: TSL billboarding (complex, error-prone)
const viewDirection = normalize(cameraPosition.sub(instancePosition));
const right = normalize(cross(viewDirection, vec3(0, 1, 0)));
const up = cross(right, viewDirection);

Enter fullscreen mode Exit fullscreen mode

Result: Complex TSL syntax caused shader compilation errors in WebGPU.

Solution 3: CPU Billboarding


// Attempt 3: Per-frame matrix updates (expensive)
for (let i = 0; i < starCount; i++) {
  const lookMatrix = new THREE.Matrix4();
  lookMatrix.lookAt(position, camera.position, camera.up);
  // Update 15,000 matrices per frame
}

Enter fullscreen mode Exit fullscreen mode

Result: 15,000 matrix calculations per frame caused performance issues.

The Elegant Solution: Dual-Hemisphere Architecture

The final solution involved architectural redesign rather than complex workarounds:

Implementation


export class Background {
  private frontStarfield!: THREE.InstancedMesh;
  private backStarfield!: THREE.InstancedMesh;
  private starGroup!: THREE.Group;

  createStarfield(): THREE.Group {
    this.starGroup = new THREE.Group();

    // Split into two hemispheres
    this.frontStarfield = this.createStarPlane(7500, true); // Front
    this.backStarfield = this.createStarPlane(7500, false); // Back

    this.starGroup.add(this.frontStarfield);
    this.starGroup.add(this.backStarfield);

    return this.starGroup;
  }

  private createStarPlane(starCount: number, isFront: boolean): THREE.InstancedMesh {
    // Generate hemisphere positions
    for (let i = 0; i < starCount; i++) {
      const phi = Math.acos(2 * Math.random() - 1);
      const theta = Math.random() * Math.PI * 2;

      // Separate front/back hemispheres
      let z = Math.cos(phi);
      if (!isFront) z = -z;

      const distance = 50; // Fixed distance eliminates perspective issues
      position.set(
        distance * Math.sin(phi) * Math.cos(theta),
        distance * Math.sin(phi) * Math.sin(theta),
        distance * z
      );

      // Calculate proper billboard rotation
      const lookDirection = new THREE.Vector3()
        .subVectors(new THREE.Vector3(0, 0, 0), position)
        .normalize();

      // Create rotation matrix to face camera
      const up = new THREE.Vector3(0, 1, 0);
      const right = new THREE.Vector3().crossVectors(up, lookDirection).normalize();
      up.crossVectors(lookDirection, right);

      const rotMatrix = new THREE.Matrix4();
      rotMatrix.makeBasis(right, up, lookDirection);
      rotMatrix.decompose(new THREE.Vector3(), quaternion, new THREE.Vector3());

      matrix.compose(position, quaternion, scale);
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Key Advantages

  1. Fixed Distance : All stars at consistent 50-unit distance eliminates perspective distortion
  2. Proper Billboarding : Each star’s rotation matrix calculated to face camera origin
  3. Hemisphere Separation : Dedicated handling for front/back visibility
  4. Performance : One-time calculation during initialization, not per-frame

Technical Benefits

Visual Quality

  • Consistent appearance across all viewing angles
  • No shape distortion regardless of camera position
  • Uniform brightness independent of perspective

Performance

  • Zero runtime cost for billboard calculations
  • GPU-optimized instanced rendering for 15,000 stars
  • Scalable to even larger star counts

Maintainability

  • Simple architecture easier to debug and extend
  • Clear separation between front/back star management
  • Reusable pattern for other billboard sprite scenarios

Enhanced Features

The solution also enabled advanced visual effects:


// TSL-based twinkling with individual star timing
const sparkle = sin(mul(timeUniform, sparkleSpeedUniform)
  .add(mul(randomAttr, float(6.28))))
  .mul(float(0.5)).add(float(0.5));

const pulse = sin(mul(timeUniform, pulseSpeedUniform)
  .add(pulsePhase))
  .mul(float(0.4)).add(float(0.6));

// Sharp-edged circular stars
const circularAlpha = smoothstep(float(0.5), float(0.2), distance);
const finalAlpha = mul(circularAlpha, mul(sparkleEnhanced, pulse));

Enter fullscreen mode Exit fullscreen mode

Features achieved:

  • Individual star twinkling with unique timing per star
  • GPU-parallelized effects for 15,000 stars with zero performance cost
  • Realistic sparkle and pulse effects mimicking atmospheric scintillation
  • Sharp-edged circular stars with configurable falloff

Performance Metrics

The final implementation delivers excellent performance:

  • 15,000 stars rendering at 60+ FPS
  • Zero CPU overhead for star animation (GPU-only)
  • Minimal memory footprint using instanced rendering
  • WebGPU optimized with Three.js TSL shaders

Lessons Learned

  1. Architectural solutions often outperform technical workarounds
  2. Fixed-distance billboards eliminate many perspective issues
  3. Hemisphere separation provides better control than sphere-based approaches
  4. GPU shader effects can be simpler than CPU-based alternatives
  5. WebGPU + TSL requires different approaches than traditional WebGL

Common Pitfalls to Avoid

Camera Clipping Issues


// ![❌](https://s.w.org/images/core/emoji/15.1.0/72x72/274c.png) Bad: Stars beyond far plane
const camera = new THREE.PerspectiveCamera(25, aspect, 0.1, 100);
const starDistance = 200; // Beyond far plane!

// ![✅](https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png) Good: Stars within camera range
const camera = new THREE.PerspectiveCamera(25, aspect, 0.1, 100);
const starDistance = 50; // Well within range

Enter fullscreen mode Exit fullscreen mode

TSL Shader Compatibility


// ![❌](https://s.w.org/images/core/emoji/15.1.0/72x72/274c.png) Bad: onBeforeCompile doesn't work with WebGPU
material.onBeforeCompile = (shader) => {
  // This won't execute in WebGPU mode
};

// ![✅](https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png) Good: Use TSL node system
const sparkle = sin(mul(timeUniform, speedUniform));
material.opacityNode = sparkle;

Enter fullscreen mode Exit fullscreen mode

Performance Anti-Patterns


// ![❌](https://s.w.org/images/core/emoji/15.1.0/72x72/274c.png) Bad: Per-frame matrix updates
animate() {
  for (let i = 0; i < 15000; i++) {
    updateStarMatrix(i); // 15k calculations per frame
  }
}

// ![✅](https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png) Good: One-time setup with GPU animation
createStars() {
  // Calculate matrices once
  material.opacityNode = gpuAnimationNode; // GPU handles animation
}

Enter fullscreen mode Exit fullscreen mode

Conclusion

Instead of fighting perspective projection with complex mathematical solutions, restructuring the problem space proved more effective. The dual-hemisphere approach provides perfect visual results while maintaining excellent performance and code clarity.

This pattern can be applied to any 3D scenario requiring consistent billboard sprite appearance across all viewing angles – from particle systems to UI elements in 3D space.

Key takeaways:

  • Question architectural assumptions when facing visual artifacts
  • GPU-based solutions often outperform CPU workarounds
  • WebGPU + TSL requires rethinking traditional Three.js patterns
  • Fixed-distance billboards solve many perspective distortion issues

Related Resources

The post Solving Starfield Perspective Distortion in 3D Space: A Three.js Case Study appeared first on Richard Fu.

Top comments (0)