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);
Why this failed:
- Close proximity (40-60 units) amplified perspective effects
- Fixed orientation meant planes viewed at oblique angles appeared stretched
- 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;
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);
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
}
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);
}
}
}
Key Advantages
- Fixed Distance : All stars at consistent 50-unit distance eliminates perspective distortion
- Proper Billboarding : Each star’s rotation matrix calculated to face camera origin
- Hemisphere Separation : Dedicated handling for front/back visibility
- 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));
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
- Architectural solutions often outperform technical workarounds
- Fixed-distance billboards eliminate many perspective issues
- Hemisphere separation provides better control than sphere-based approaches
- GPU shader effects can be simpler than CPU-based alternatives
- WebGPU + TSL requires different approaches than traditional WebGL
Common Pitfalls to Avoid
Camera Clipping Issues
//  Bad: Stars beyond far plane
const camera = new THREE.PerspectiveCamera(25, aspect, 0.1, 100);
const starDistance = 200; // Beyond far plane!
//  Good: Stars within camera range
const camera = new THREE.PerspectiveCamera(25, aspect, 0.1, 100);
const starDistance = 50; // Well within range
TSL Shader Compatibility
//  Bad: onBeforeCompile doesn't work with WebGPU
material.onBeforeCompile = (shader) => {
// This won't execute in WebGPU mode
};
//  Good: Use TSL node system
const sparkle = sin(mul(timeUniform, speedUniform));
material.opacityNode = sparkle;
Performance Anti-Patterns
//  Bad: Per-frame matrix updates
animate() {
for (let i = 0; i < 15000; i++) {
updateStarMatrix(i); // 15k calculations per frame
}
}
//  Good: One-time setup with GPU animation
createStars() {
// Calculate matrices once
material.opacityNode = gpuAnimationNode; // GPU handles animation
}
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
- Three WebGPU Renderer
- TSL (Three.js Shading Language) Guide
- Instanced Rendering Best Practices
- Billboard Sprite Techniques in 3D Graphics
The post Solving Starfield Perspective Distortion in 3D Space: A Three.js Case Study appeared first on Richard Fu.
Top comments (0)