Skip to main content

Lighting & Day/Night Cycle

Hyperscape features a dynamic day/night cycle with adaptive lighting and auto exposure that mimics realistic eye adaptation. The lighting system provides atmospheric transitions while maintaining gameplay visibility.
Lighting code lives in packages/shared/src/systems/shared/world/Environment.ts with sky system in SkySystem.ts.

Auto Exposure System

The auto exposure system mimics eye adaptation to different light levels, automatically adjusting exposure to keep the game visible during both day and night:
// Environment.ts
private readonly DAY_EXPOSURE = 0.85;   // Standard exposure for bright daylight
private readonly NIGHT_EXPOSURE = 1.7;  // Boosted exposure for night visibility
private currentExposure: number = 0.85; // Smoothed current value

How It Works

private updateAutoExposure(dayIntensity: number): void {
  // Calculate target exposure using smoothstep interpolation
  const t = dayIntensity * dayIntensity * (3 - 2 * dayIntensity);
  const targetExposure = 
    this.NIGHT_EXPOSURE + (this.DAY_EXPOSURE - this.NIGHT_EXPOSURE) * t;
  
  // Smooth interpolation to prevent jarring changes
  // Lerp factor of 0.03 = gradual adaptation over ~30 frames
  this.currentExposure += (targetExposure - this.currentExposure) * 0.03;
  
  // Apply to renderer
  graphics.renderer.toneMappingExposure = this.currentExposure;
}
Exposure Values:
TimeExposureEffect
Day0.85Standard daylight rendering
Dusk/Dawn0.85 → 1.7Smooth transition
Night1.7Boosted to maintain visibility
Higher exposure at night compensates for lower light levels, keeping the game playable while maintaining the darker atmosphere.

Initialization

Exposure is initialized based on current time of day to prevent jarring transitions when players join at night:
// In start() after skySystem is ready
const initialDayIntensity = this.skySystem?.dayIntensity ?? 1.0;
const t = initialDayIntensity * initialDayIntensity * (3 - 2 * initialDayIntensity);
this.currentExposure = this.NIGHT_EXPOSURE + (this.DAY_EXPOSURE - this.NIGHT_EXPOSURE) * t;

Day/Night Lighting

Sun and Moon

The sun/moon light transitions based on time of day:
// Daytime - warm sunlight
if (dayIntensity > 0.1) {
  const sunIntensity = dayIntensity * 1.8 * transitionFade;
  this.sunLight.intensity = sunIntensity;
  this.sunLight.color.setRGB(1.0, 0.98, 0.92);
} else {
  // Nighttime - cool blue moonlight (stronger for better visibility)
  const nightIntensity = 1 - dayIntensity;
  const moonIntensity = nightIntensity * 0.6 * transitionFade;
  this.sunLight.intensity = moonIntensity;
  this.sunLight.color.setRGB(0.6, 0.7, 0.9);
}
Light Intensity:
TimeIntensityColor
Day1.8Warm white (1.0, 0.98, 0.92)
Night0.6Cool blue (0.6, 0.7, 0.9)
Moon intensity was increased from 0.4 to 0.6 to improve night visibility while maintaining atmospheric darkness.

Ambient Lighting

Hemisphere and ambient lights provide base visibility:
// Hemisphere light: brighter during day, visible at night
// Day: 0.9, Night: 0.4 (auto exposure handles the rest)
this.hemisphereLight.intensity = 0.4 + dayIntensity * 0.5;

// Shift sky color from bright blue (day) to blue-silver (night)
this.hemisphereLight.color.setRGB(
  0.53 * dayIntensity + 0.25 * nightIntensity, // R: moonlit sky
  0.81 * dayIntensity + 0.35 * nightIntensity, // G: moonlit sky
  0.92 * dayIntensity + 0.5 * nightIntensity,  // B: blue tint at night
);

// Ambient fill: provides base visibility
// Day: 0.5, Night: 0.3 (auto exposure handles the rest)
this.ambientLight.intensity = 0.3 + dayIntensity * 0.2;

// Day: warm neutral white, Night: brighter blue moonlight tint
this.ambientLight.color.setRGB(
  0.5 + dayIntensity * 0.5,   // R: 0.5 at night, 1.0 at day
  0.55 + dayIntensity * 0.4,  // G: 0.55 at night, 0.95 at day
  0.7 + dayIntensity * 0.25,  // B: 0.7 at night, 0.95 at day (bluer at night)
);
Ambient Intensity:
Light TypeDayNight
Hemisphere0.90.4
Ambient0.50.3

Shadow System

Cascaded Shadow Maps (WebGPU)

WebGPU uses Cascaded Shadow Maps (CSM) for high-quality shadows:
this.csmShadowNode = new CSMShadowNode(this.sunLight, {
  cascades: csmConfig.cascades,
  maxFar: csmConfig.maxFar,
  shadowMapSize: csmConfig.shadowMapSize,
  shadowBias: csmConfig.shadowBias,
  shadowNormalBias: csmConfig.shadowNormalBias,
});
Shadow Quality Levels:
LevelCascadesMap SizeMax Distance
Low21024100 tiles
Medium32048150 tiles
High44096200 tiles

WebGL Shadow Fallback

WebGL uses simplified single directional light shadows:
if (!useWebGPU) {
  this.csmShadowNode = null;
  this.csmNeedsAttach = false;
  this.needsFrustumUpdate = false;
  
  scene.add(this.sunLight);
  scene.add(this.sunLight.target);
  
  console.log(
    `[Environment] WebGL shadow map enabled (no CSM): mapSize=${csmConfig.shadowMapSize}, frustum=${baseFrustumSize * 2}`
  );
  return;
}
WebGL Limitations:
  • No cascaded shadows (single shadow map)
  • Smaller shadow coverage area
  • Lower shadow quality
  • Still provides functional shadows for gameplay

Fog System

Fog color transitions with day/night cycle:
// Day fog color: warm beige
private readonly dayFogColor = new THREE.Color(0xd4c8b8);

// Night fog color: dark blue to blend with night sky
private readonly nightFogColor = new THREE.Color(0x1a1f3a);

private updateFogColor(dayIntensity: number): void {
  if (this.world.scene?.fog) {
    this.world.scene.fog.color.lerpColors(
      this.nightFogColor,
      this.dayFogColor,
      dayIntensity
    );
  }
}

CSM Frustum Initialization

CSM frustums are initialized during startup to ensure shadows work from the first frame:
private initializeCSMFrustums(): void {
  if (!this.csmShadowNode || !this.needsFrustumUpdate) return;
  
  const camera = this.world.camera;
  
  // Validate camera is properly configured
  if (camera.aspect <= 0 || camera.fov <= 0 || camera.near <= 0) {
    console.debug("[Environment] CSM init deferred - camera not configured yet");
    return;
  }
  
  // Ensure CSM has camera reference
  if (!this.csmShadowNode.camera) {
    this.csmShadowNode.camera = camera;
  }
  
  // Update camera matrices before frustum calculation
  camera.updateProjectionMatrix();
  camera.updateMatrixWorld(true);
  
  try {
    this.csmShadowNode.updateFrustums();
    this.needsFrustumUpdate = false;
    
    // Attach shadowNode to light
    if (this.csmNeedsAttach && this.sunLight) {
      this.sunLight.shadow.shadowNode = this.csmShadowNode;
      this.csmNeedsAttach = false;
      console.log("[Environment] CSM shadowNode attached to light (init)");
    }
  } catch (err) {
    // Will be retried during update() - expected during startup
    console.debug("[Environment] CSM init deferred:", err.message);
  }
}
This prevents shadow initialization failures that could occur when camera projection isn’t ready yet.

Lighting Constants

// From Environment.ts
const LIGHT_DISTANCE = 400;  // Distance from target to light

// Auto exposure
const DAY_EXPOSURE = 0.85;
const NIGHT_EXPOSURE = 1.7;

// Moon intensity (increased for better night visibility)
const MOON_INTENSITY_MULTIPLIER = 0.6;  // Was 0.4

// Ambient lighting floors
const HEMISPHERE_NIGHT_INTENSITY = 0.4;  // Was 0.25
const AMBIENT_NIGHT_INTENSITY = 0.3;     // Was 0.18

Performance Considerations

Frustum Update Optimization

CSM frustums are only recalculated when needed (expensive operation):
// Frustum recalculation is needed on:
// - Viewport resize
// - Camera near/far change
// Light position updates do NOT require frustum recalculation

if (this.csmShadowNode && this.needsFrustumUpdate) {
  // Pre-flight checks: ensure camera has valid projection
  const hasValidAspect = camera.aspect > 0;
  const hasValidFov = camera.fov > 0;
  const hasValidNearFar = camera.near > 0 && camera.far > camera.near;
  
  if (!hasValidAspect || !hasValidFov || !hasValidNearFar) {
    // Camera not fully configured yet - skip this frame
    return;
  }
  
  camera.updateProjectionMatrix();
  camera.updateMatrixWorld(true);
  
  this.csmShadowNode.updateFrustums();
  this.needsFrustumUpdate = false;
}

Smooth Transitions

All lighting transitions use interpolation to prevent jarring changes:
// Smoothstep for natural-feeling transitions
const t = dayIntensity * dayIntensity * (3 - 2 * dayIntensity);

// Gradual exposure adaptation (0.03 lerp factor = ~30 frames)
this.currentExposure += (targetExposure - this.currentExposure) * 0.03;