Hiding loading transitions in a browser game

2025-12-18~4 min read

The problem: objects pop in while loading

  • The game starts while some objects are still missing
    • If GLB models/textures are not ready, objects appear later suddenly
    • It looks bad and hurts UX
  • I wanted an easy way to register “loading work”
    • Long tasks should be trackable without knowing loader internals
    • GLB loading, texture loading, physics world init, etc. should be unified

Solution: `SceneLoadTracker`

  • Track Promises per scene
    • Register with track() and keep a counter of pending work
  • Wait for idle before starting render/gameplay
    • waitForIdle() waits until pending work becomes 0
    • Prevents pop-in by starting after all loads are finished
  • Simple API
    • Just pass the Promise to track()

SceneLoadTracker Implementation Details

SceneLoadTracker is implemented as a singleton and manages loading state per scene ID.

export class SceneLoadTracker {
  private static instance: SceneLoadTracker | null = null;
  private stateBySceneId = new Map<string, SceneLoadState>();

  track<T>(sceneId: string, promise: Promise<T>): Promise<T> {
    const state = this.ensure(sceneId);
    state.total += 1;
    state.pendingPromises.add(promise);
    
    promise.finally(() => {
      state.completed += 1;
      state.pendingPromises.delete(promise);
      this.notify(sceneId);
    });
    
    return promise;
  }

  waitForIdle(sceneId: string): Promise<void> {
    const snap = this.snapshot(sceneId);
    if (snap.pending === 0) {
      return Promise.resolve();
    }
    
    return new Promise((resolve) => {
      const unsubscribe = this.subscribe(sceneId, (s) => {
        if (s.pending === 0) {
          unsubscribe();
          resolve();
        }
      });
    });
  }
}

Usage Example

// Track GLB model loading
const loader = new GLTFLoader();
const loadPromise = scene.trackLoad(
  loader.loadAsync('model.glb')
);

// Track texture loading
const texturePromise = scene.trackLoad(
  new THREE.TextureLoader().loadAsync('texture.jpg')
);

// Wait for all loads to complete
await scene.waitForLoadingIdle();

// Start the game from here
scene.start();