Files
suaveplan/.claude/plugins/suaveplan-plugins/plugins/suaveplan-webgpu/skills/webgpu-threejs-tsl/docs/device-loss.md
2026-05-27 23:29:53 +01:00

10 KiB

GPU Device Loss and Recovery

GPU device loss occurs when the browser's WebGPU device becomes unavailable. Common causes: driver crash, GPU hang, system sleep/wake, GPU reset by OS, resource exhaustion, or the user switching GPUs. Your application must handle this gracefully.

How Device Loss Manifests

When the GPU device is lost:

  1. All GPU resources (buffers, textures, pipelines) become invalid.
  2. Subsequent GPU operations silently fail or throw.
  3. The canvas may go black or freeze on the last rendered frame.
  4. GPUDevice.lost promise resolves with a GPUDeviceLostInfo object.

Detection

WebGPU Native API

const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();

device.lost.then((info) => {
  console.error(`GPU device lost: ${info.reason}`, info.message);
  if (info.reason === 'destroyed') {
    // Intentional -- app called device.destroy()
  } else {
    // Unexpected loss -- attempt recovery
    recoverDevice();
  }
});

Three.js WebGPURenderer

Three.js wraps the device internally. You can detect loss by listening on the renderer's underlying device:

import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';

async function setupRenderer(canvas: HTMLCanvasElement) {
  const renderer = new WebGPURenderer({ canvas, antialias: true });
  await renderer.init();

  // Access the underlying GPUDevice
  const backend = renderer.backend;
  const device = backend.device;

  device.lost.then((info) => {
    console.error('WebGPU device lost:', info.reason, info.message);
    handleDeviceLoss(renderer);
  });

  return renderer;
}

React Error Boundary for Device Loss

Wrap your WebGPU canvas in an error boundary that catches device-loss errors and offers recovery.

DeviceLossErrorBoundary Component

import { Component, type ReactNode } from 'react';

interface DeviceLossState {
  hasError: boolean;
  error: Error | null;
  recovering: boolean;
}

interface DeviceLossProps {
  children: ReactNode;
  fallback?: ReactNode;
  onDeviceLost?: (error: Error) => void;
  maxRetries?: number;
}

export class DeviceLossErrorBoundary extends Component<
  DeviceLossProps,
  DeviceLossState
> {
  private retryCount = 0;

  constructor(props: DeviceLossProps) {
    super(props);
    this.state = { hasError: false, error: null, recovering: false };
  }

  static getDerivedStateFromError(error: Error): Partial<DeviceLossState> {
    // Check if the error is GPU-related
    const isGPUError =
      error.message.includes('device') ||
      error.message.includes('GPU') ||
      error.message.includes('context lost') ||
      error.message.includes('destroyed') ||
      error.name === 'GPUDeviceLostError';

    if (isGPUError) {
      return { hasError: true, error };
    }
    // Re-throw non-GPU errors
    throw error;
  }

  componentDidCatch(error: Error) {
    this.props.onDeviceLost?.(error);
  }

  handleRetry = () => {
    const maxRetries = this.props.maxRetries ?? 3;

    if (this.retryCount >= maxRetries) {
      return; // Give up after max retries
    }

    this.retryCount++;
    this.setState({ hasError: false, error: null, recovering: true });

    // Small delay to let GPU recover
    setTimeout(() => {
      this.setState({ recovering: false });
    }, 1000);
  };

  render() {
    if (this.state.hasError) {
      if (this.props.fallback) {
        return this.props.fallback;
      }

      return (
        <div style={{ padding: 20, textAlign: 'center' }}>
          <h3>GPU Connection Lost</h3>
          <p>The graphics device became unavailable.</p>
          <button onClick={this.handleRetry}>
            Retry ({this.retryCount}/{this.props.maxRetries ?? 3})
          </button>
        </div>
      );
    }

    if (this.state.recovering) {
      return <div style={{ padding: 20 }}>Reconnecting to GPU...</div>;
    }

    return this.props.children;
  }
}

Usage in R3F

function App() {
  return (
    <DeviceLossErrorBoundary
      maxRetries={3}
      onDeviceLost={(err) => {
        analytics.track('gpu_device_lost', { message: err.message });
      }}
      fallback={
        <div>
          <p>3D rendering is unavailable.</p>
          <p>Try refreshing the page or switching to a different browser.</p>
        </div>
      }
    >
      <WebGPUCanvas>
        <Scene />
      </WebGPUCanvas>
    </DeviceLossErrorBoundary>
  );
}

Recovery Strategies

Strategy 1: Full Remount (Simple, Reliable)

Unmount and remount the entire Canvas. This destroys all Three.js state and recreates it from scratch.

function RecoverableScene() {
  const [key, setKey] = useState(0);

  const handleDeviceLoss = useCallback(() => {
    // Increment key to force full remount
    setTimeout(() => setKey((k) => k + 1), 1000);
  }, []);

  return (
    <DeviceLossErrorBoundary
      key={key}
      onDeviceLost={handleDeviceLoss}
    >
      <WebGPUCanvas>
        <Scene />
      </WebGPUCanvas>
    </DeviceLossErrorBoundary>
  );
}

Strategy 2: Renderer Recreation (Preserves Scene Graph)

More sophisticated -- recreate only the renderer while keeping the scene graph intact. This requires accessing Three.js internals.

function useDeviceLossRecovery() {
  const { gl, set } = useThree();

  useEffect(() => {
    const renderer = gl as unknown as WebGPURenderer;
    const backend = renderer.backend;

    if (!backend?.device) return;

    backend.device.lost.then(async (info) => {
      console.warn('GPU device lost, attempting recovery...');

      // Create a new renderer on the same canvas
      const canvas = renderer.domElement;
      const newRenderer = new WebGPURenderer({
        canvas,
        antialias: true,
      });
      await newRenderer.init();

      // Replace the renderer in R3F state
      set({ gl: newRenderer as unknown as THREE.WebGLRenderer });
    });
  }, [gl, set]);
}

Strategy 3: Graceful Degradation to WebGL

Fall back to WebGL when WebGPU fails:

async function createRendererWithFallback(canvas: HTMLCanvasElement) {
  // Try WebGPU first
  if (navigator.gpu) {
    try {
      const renderer = new WebGPURenderer({ canvas, antialias: true });
      await renderer.init();
      return { renderer, backend: 'webgpu' as const };
    } catch {
      console.warn('WebGPU init failed, falling back to WebGL');
    }
  }

  // WebGL fallback
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  return { renderer, backend: 'webgl' as const };
}

State Preservation

When recovering from device loss, you may want to preserve application state (camera position, animation progress, user selections). Store critical state outside the Three.js scene graph.

interface SceneState {
  cameraPosition: [number, number, number];
  cameraTarget: [number, number, number];
  animationProgress: number;
  selectedObjectId: string | null;
}

function useSceneStateBackup() {
  const stateRef = useRef<SceneState>({
    cameraPosition: [0, 5, 10],
    cameraTarget: [0, 0, 0],
    animationProgress: 0,
    selectedObjectId: null,
  });

  // Periodically snapshot camera state
  useFrame(({ camera }) => {
    stateRef.current.cameraPosition = camera.position.toArray();
  });

  return stateRef;
}

After recovery, restore from the backup:

function Scene({ initialState }: { initialState?: SceneState }) {
  const cameraRef = useRef<THREE.PerspectiveCamera>(null!);

  useEffect(() => {
    if (initialState) {
      cameraRef.current.position.fromArray(initialState.cameraPosition);
    }
  }, [initialState]);

  return (
    <perspectiveCamera ref={cameraRef} />
  );
}

Compute Shader Buffer Recovery

Storage buffers for compute shaders are lost along with the device. If the simulation state is important, periodically snapshot it:

function useComputeStateBackup(
  renderer: WebGPURenderer,
  storageBuffer: StorageBuffer,
  intervalMs = 5000
) {
  const backupRef = useRef<Float32Array | null>(null);

  useEffect(() => {
    const interval = setInterval(async () => {
      try {
        const data = await renderer.getArrayBufferAsync(storageBuffer.value);
        backupRef.current = new Float32Array(data);
      } catch {
        // Device may already be lost
      }
    }, intervalMs);

    return () => clearInterval(interval);
  }, [renderer, storageBuffer, intervalMs]);

  return backupRef;
}

Testing Device Loss

Simulating Device Loss in Tests

// In a Vitest test
describe('device loss recovery', () => {
  it('should remount canvas on device loss', async () => {
    const onDeviceLost = vi.fn();

    const { rerender } = render(
      <DeviceLossErrorBoundary onDeviceLost={onDeviceLost}>
        <WebGPUCanvas>
          <Scene />
        </WebGPUCanvas>
      </DeviceLossErrorBoundary>
    );

    // Simulate device loss by triggering error boundary
    // In a real scenario, the GPU device.lost promise resolves
    // For testing, throw a simulated GPU error
  });
});

Chrome DevTools

  1. Open Chrome DevTools.
  2. Go to chrome://gpu to see GPU status.
  3. Use chrome://flags/#force-gpu-rasterization for GPU stress testing.
  4. The WebGPU spec provides device.destroy() for testing intentional loss.

Programmatic Device Destruction

// Intentionally destroy device for testing recovery
function testDeviceLoss(renderer: WebGPURenderer) {
  const device = renderer.backend.device;
  device.destroy(); // Triggers device.lost promise
}

Integration with @suaveplan/r3f-performance

The @suaveplan/r3f-performance package monitors frame timing and GPU utilization. On device loss, the performance monitor should:

  1. Detect the anomaly (sudden drop to 0 FPS or error events).
  2. Emit a 'device-lost' event via the performance event bus.
  3. Pause metric collection during recovery.
  4. Resume collection after the new device is initialized.
  5. Log a recovery marker in the performance timeline.
// Performance monitor integration
performanceMonitor.on('device-lost', () => {
  performanceMonitor.pause();
});

performanceMonitor.on('device-recovered', () => {
  performanceMonitor.resume();
  performanceMonitor.addMarker('gpu-recovery');
});

Checklist

When implementing WebGPU in a Suaveplan R3F component:

  • Wrap the Canvas in DeviceLossErrorBoundary
  • Provide meaningful fallback UI
  • Preserve important scene state outside the GPU
  • Test with device.destroy() during development
  • Log device loss events to analytics
  • Consider WebGL fallback for critical features
  • Handle async renderer init errors
  • Clean up GPU resources in React effect cleanup functions