← All articles

Take a screenshot of a Three.js scene

Updated on . Tested with three.js r186 and R3F 9.

Sometimes you need to save a screenshot of what your three.js scene shows. This could be when your user has built a furniture configuration and you want to show it in their cart, or simply because you have designed an awesome graphic that you want to share with the world. Here are some tips and tricks for getting the best results, starting with the one that matters most. Render a frame and read the canvas straight after it, in the same function, or the picture comes back empty.

Save the canvas as a PNG

Render a frame and call toBlob() on the renderer's canvas straight after it, in the same function.

function screenshot(type = 'image/png'): Promise<Blob> {
  renderer.render(scene, camera);
  return new Promise((resolve, reject) => {
    renderer.domElement.toBlob((blob) => {
      if (blob) resolve(blob);
      else reject(new Error('The canvas is empty'));
    }, type);
  });
}

screenshot() gives you the frame as a Blob. To let the shopper download it, hand it to a link and click it.

function download(blob: Blob, name: string) {
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = name;
  link.click();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

document.querySelector('#save')?.addEventListener('click', async () => {
  download(await screenshot(), 'configuration.png');
});

Pass 'image/jpeg' or 'image/webp' for a smaller file. toBlob is better than toDataURL here, because it does not build a base64 string several megabytes long on the main thread.

The PNG saved by screenshot() shows an orange knot on a transparent background, here on a checkerboard

The image is black or empty

A WebGL canvas is cleared once the browser has put the frame on screen. By default three.js creates its renderer with preserveDrawingBuffer: false because it's more performant. So a toDataURL() or toBlob() called from a click handler reads a canvas that was already cleared. The PNG you get is fully transparent. On a dark page or in some image viewers that looks black, and saved as a JPEG it is solid black.

There are two ways to fix it.

  1. Render right before you read, in the same function, as screenshot() does. Use this one.
  2. Create the renderer with preserveDrawingBuffer: true. Reading later now works, but on some devices that makes the whole page slower.

If you use post-processing, call composer.render() in screenshot() instead of renderer.render(), or the picture is saved without its effects.

Save it larger than the screen

The canvas is as big as it is on the page, times the screen's pixel ratio. A viewport 1,200 pixels wide gives a 2,400 pixel image on a laptop and often less on a phone. For a print or a PDF, draw one frame at the size you need and set everything back.

async function screenshotAt(width: number, height: number): Promise<Blob> {
  const size = renderer.getSize(new THREE.Vector2());
  const ratio = renderer.getPixelRatio();
  const aspect = camera.aspect;

  renderer.setPixelRatio(1);
  renderer.setSize(width, height, false);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();

  const gl = renderer.getContext();
  if (gl.drawingBufferWidth < width || gl.drawingBufferHeight < height) {
    console.warn(`The browser gave ${gl.drawingBufferWidth}×${gl.drawingBufferHeight}, not ${width}×${height}`);
  }

  try {
    return await screenshot();
  } finally {
    renderer.setPixelRatio(ratio);
    renderer.setSize(size.x, size.y, false);
    camera.aspect = aspect;
    camera.updateProjectionMatrix();
  }
}

To get a specific resolution for your export, do the following.

  • Set the pixel ratio to 1, so width and height are the pixels you get.
  • Pass false as the third argument to setSize. It resizes what is drawn and leaves the canvas's size on the page alone, so nothing on screen jumps.
  • Set camera.aspect to the image's width divided by its height and call camera.updateProjectionMatrix(), or a square image of a wide viewport comes out squashed. An image narrower than the viewport shows less at the sides, so if the product no longer fits, move the camera back or raise camera.fov for the capture.

GPUs have a maximum size for what they render. If you ask for more, the browser makes the canvas smaller without an error. drawingBufferWidth tells you the size you really got, which is why the function checks it.

Keep the background transparent

If you need a transparent background, create the renderer with alpha: true and leave the scene's background empty.

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
scene.background = null;

PNG and WebP keep the transparency. JPEG has none, so the transparent pixels come out black.

The same frame saved as image/jpeg shows the knot on solid black, because JPEG has no transparency

Send the screenshot to your server with the shopper's choices

Often the shop needs to see what the shopper built, to price it or to make it. Send the picture to your server together with the choices that made it, at a size that prints.

async function sendConfiguration(configuration: Record<string, string>) {
  const form = new FormData();
  form.append('image', await screenshotAt(2048, 1536), 'configuration.png');
  form.append('configuration', JSON.stringify(configuration));
  await fetch('/api/configurations', { method: 'POST', body: form });
}

On your server it is an ordinary file upload.

Take a screenshot in React Three Fiber

React Three Fiber runs the render loop for you, so take the renderer, the scene and the camera from useThree in a component inside the <Canvas>, and hand a capture function out through a ref. The button stays outside the canvas, with the rest of your interface.

type Capture = () => Promise<Blob>;

function CaptureHandle({ capture }: { capture: RefObject<Capture | null> }) {
  const { gl, scene, camera } = useThree();
  useEffect(() => {
    capture.current = () => {
      gl.render(scene, camera);
      return new Promise((resolve, reject) => {
        gl.domElement.toBlob((blob) => {
          if (blob) resolve(blob);
          else reject(new Error('The canvas is empty'));
        }, 'image/png');
      });
    };
  }, [gl, scene, camera, capture]);
  return null;
}

export function Configurator() {
  const capture = useRef<Capture | null>(null);
  return (
    <>
      <Canvas>
        <CaptureHandle capture={capture} />
        <mesh>
          <boxGeometry />
          <meshStandardMaterial color="tomato" />
        </mesh>
      </Canvas>
      <button
        onClick={async () => {
          const blob = await capture.current?.();
          if (blob) download(blob, 'configuration.png');
        }}
      >
        Save image
      </button>
    </>
  );
}

React Three Fiber already creates its renderer with alpha: true, so the background is transparent unless you give the scene one. You do not need gl={{ preserveDrawingBuffer: true }} either, because the capture renders its own frame first, exactly like the vanilla version.

How to make it look even better

The Three.js viewport and the same scene rendered, side by side: an orange knot on a light background, lit from the upper left
This scene in the Three.js viewport, and the same scene rendered with Bakery3 from the code in this article.

A screenshot is exactly the viewport, with the same shadow maps, the same reflections of the environment map and the same edges. For a thumbnail in a cart that is what you want.

When the picture has to look like a photo, for an order confirmation, a PDF or an image the shopper shares, Bakery3 renders the same scene with a path tracer. It takes a few lines beside your screenshot code.

// An API key while you test. In production, use a token from your server instead:
// https://bakery3.com/docs#let-your-users-render-without-exposing-your-api-key
const bakery3 = createBakery3({ apiKey: 'bk_sk_…' });
bakery3.attach({ scene, camera, renderer });

async function photo(): Promise<string> {
  const job = await bakery3.render({ width: 2048 });
  const result = await job.result();
  return result.url;
}

photo() returns a link to a PNG of the scene exactly as the shopper left it, traced with its own materials, lights and camera, with a transparent background by default. It takes longer than a screenshot and each image is charged, so most configurators keep the screenshot for the cart and render only the pictures that have to look their best. The quickstart sets up the token endpoint, and Render image has every option.

Below is the same chair in a Three.js viewport and traced from that scene.

Fabric is where a rasteriser runs out of road. The sheen along a fold is light that entered the weave and left it somewhere else. The traced frame also grounds the legs, and that contact shadow was not authored.

240 frames at 60fps·preview preset

Common questions

Why does toDataURL return a black or blank image in three.js?

Three.js creates its WebGLRenderer with preserveDrawingBuffer: false, so the browser can clear the canvas once a frame is on screen. A toDataURL() called later, from a click handler, reads that cleared canvas. Call renderer.render(scene, camera) right before toDataURL() or toBlob(), in the same function, so you read the canvas before the browser clears it.

Can I turn on preserveDrawingBuffer only when I take a three.js screenshot?

No, not on a renderer that already exists. preserveDrawingBuffer is a WebGL context attribute, fixed when the three.js WebGLRenderer creates its context, and leaving it on makes the browser keep every frame, which the WebGL specification warns can be slower on some devices. Call renderer.render(scene, camera) right before toBlob() instead, and the default false works.

How do I take a high resolution screenshot of a three.js scene?

Render one frame at the size you need, then put the renderer back. Call renderer.setPixelRatio(1) and renderer.setSize(width, height, false), set camera.aspect to the new shape with camera.updateProjectionMatrix(), render, read the canvas with toBlob() and restore the old values. The GPU limits how large a canvas can be and the browser shrinks it without an error, so check gl.drawingBufferWidth before you trust the size.

How do I save a three.js canvas as a JPEG?

Pass 'image/jpeg' to the canvas's toBlob() or toDataURL(), with an optional quality from 0 to 1, for example renderer.domElement.toBlob(callback, 'image/jpeg', 0.9). JPEG has no transparency, so a three.js renderer created with alpha: true and no scene.background saves its empty pixels as black. Give the scene a background color first, such as scene.background = new THREE.Color('white').

Do I need preserveDrawingBuffer to take a screenshot in React Three Fiber?

No, not if the capture renders its own frame. React Three Fiber creates its WebGLRenderer without preserveDrawingBuffer, so it is false, but calling gl.render(scene, camera) from useThree right before gl.domElement.toBlob() reads the canvas before the browser clears it. Only a toDataURL() in a click handler that does not render first needs gl={{ preserveDrawingBuffer: true }} on the Canvas.