# Budget the GPU for WebGL

**SHOULD** · **ID:** `performance-webgl-gpu-budget` · **Category:** performance
**Source:** [Web Platform](https://web.dev/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/performance-webgl-gpu-budget

> SHOULD: WebGL cost scales with pixels and object count, not DOM size. Cap pixel ratio at 2 (setPixelRatio(Math.min(devicePixelRatio, 2)) — fragment cost is quadratic in DPR), keep draw calls low by merging/instancing static geometry (~<100/frame), and dispose() geometries, materials, and textures on unmount because GPU memory is not garbage-collected. Keep mobile textures ≤1024².

Cap pixel ratio at ~2, keep draw calls low, and dispose GPU resources — WebGL cost scales with pixels and object count, not DOM size

Distilled from the Core Web Vitals / mobile / draw-call budgets in emalorenzo's three-agent-skills and web.dev's WebGL guidance, and it matters because WebGL performance is governed by metrics that have nothing to do with the rest of the page. Two costs dominate. Fragment shading scales with the pixels drawn, which is `cssPixels × devicePixelRatio²`, so `renderer.setPixelRatio(window.devicePixelRatio)` on a 3× phone quietly asks the GPU to shade NINE times the area of a 1× render — the single most common cause of a 3D hero that melts a mid-range phone. Cap it hard: `setPixelRatio(Math.min(devicePixelRatio, 2))`, above which the extra density is imperceptible and only expensive. Second, every draw call carries fixed CPU overhead, so a scene is bounded by object COUNT more than triangle count; hundreds of separate meshes stall the main thread, and the fix is to merge static geometry or use instancing to draw many copies in one call (budget: well under ~100 draw calls/frame). A third, silent failure: GPU memory is not garbage-collected — geometries, materials and textures must be explicitly `.dispose()`d when a scene unmounts, or they leak until the browser drops the context. This is the runtime companion to content-canvas-accessible-fallback (the same 3D feature needs a static fallback where WebGL is weak or absent) and to performance-raf-stop-condition / animations-ibelick-pause-offscreen (do not run the render loop when nothing is on screen).

## Rule snippet

```tsx
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
useEffect(() => () => { geometry.dispose(); material.dispose(); }, []);
```

## Bad — do not do this

`performance-webgl-gpu-budget-bad`

```tsx
import { useEffect, useState } from 'react';

/**
 * The three classic WebGL budget mistakes at once: an uncapped pixel ratio (quadratic
 * fragment cost on retina/mobile), one draw call per object, and no disposal on unmount.
 * The live line shows the pixel-area multiplier on the viewer's own screen.
 */
export function WebglGpuBudgetBad() {
  const [dpr, setDpr] = useState(1);
  useEffect(() => setDpr(window.devicePixelRatio || 1), []);
  const factor = (dpr * dpr).toFixed(2);

  return (
    <div className="w-full max-w-sm">
      <div className="rounded-lg border border-border bg-card p-4">
        <pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs text-foreground">
          <code>{`renderer.setPixelRatio(
  window.devicePixelRatio   // uncapped
)
// 400 separate meshes → 400 draw calls
// geometry/material/texture never disposed`}</code>
        </pre>
        <p className="mt-3 font-mono text-xs text-destructive">
          Your screen: dpr {dpr} → shading {factor}× the pixel area
        </p>
      </div>
      <p className="mt-4 text-xs text-destructive">
        Uncapped ratio + hundreds of draw calls + no disposal — the reliable way to melt a mid-range phone.
      </p>
    </div>
  );
}
```

## Good — do this

`performance-webgl-gpu-budget-good`

```tsx
import { useEffect, useState } from 'react';

/**
 * The same scene inside budget: pixel ratio capped at 2 (above which the extra density
 * is imperceptible and only expensive), geometry instanced into one draw call, and GPU
 * resources disposed on unmount. Compare the live multiplier to the uncapped version.
 */
export function WebglGpuBudgetGood() {
  const [dpr, setDpr] = useState(1);
  useEffect(() => setDpr(window.devicePixelRatio || 1), []);
  const capped = Math.min(dpr, 2);
  const factor = (capped * capped).toFixed(2);

  return (
    <div className="w-full max-w-sm">
      <div className="rounded-lg border border-border bg-card p-4">
        <pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs text-foreground">
          <code>{`renderer.setPixelRatio(
  Math.min(window.devicePixelRatio, 2)
)
// merged / instanced → 1 draw call
useEffect(() => () => {
  geometry.dispose(); material.dispose()
}, [])`}</code>
        </pre>
        <p className="mt-3 font-mono text-xs text-success">
          Your screen: dpr {dpr} → capped to {capped}, shading {factor}× the pixel area
        </p>
      </div>
      <p className="mt-4 text-xs text-success">
        Ratio capped, geometry instanced to one draw call, GPU memory freed on unmount.
      </p>
    </div>
  );
}
```

## References

- [emalorenzo — three-agent-skills](https://github.com/emalorenzo/three-agent-skills)
- [MDN — WebGL best practices](https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices)
- [web.dev — Rendering performance](https://web.dev/articles/rendering-performance)
