A rendered living room: a tall oak bookshelf on the left, a navy sofa seen
              from behind, a lit floor lamp throwing a warm pool on the wall, and cool
              daylight coming through a full-height window across the far wall.

Notes from the studio

The screenshot that comes back blank

The picture at the top of this page was taken by the application it is a picture of. You press P in Duimstok and the room you are standing in lands in a gallery you can download from. It is a small feature. Getting it right meant disagreeing with essentially every answer on the internet.

Search for why a WebGL screenshot comes back blank and you will find one recommendation, repeated everywhere with justified confidence: construct the renderer with preserveDrawingBuffer: true. It works. Duimstok does not do it, and the reason is worth ten minutes.

Why canvas.toDataURL returns a blank image

A WebGL drawing buffer is not a persistent image. The specification allows the implementation to throw its contents away as soon as the browser has composited the page, and every real implementation takes that permission, because it means the buffer can be handed straight to the compositor instead of copied.

So this is the shape of the bug. You wire a button to a handler, the handler calls canvas.toDataURL(), and you get back a transparent or black rectangle of exactly the right dimensions. Nothing errors. The canvas is fine, the scene is fine, and the image is empty — because between the frame being drawn and your handler running, the browser composited, and the pixels you wanted stopped existing.

The same thing catches people using requestAnimationFrame to "wait until it has rendered", which reliably waits until just after the wrong moment.

The cost of preserveDrawingBuffer

preserveDrawingBuffer: true tells the implementation it may not discard the buffer. The pixels are still there whenever you ask, from any task, at any time. The naive code starts working and stays working.

What it costs is a copy. The compositor can no longer take ownership of the buffer, so the contents have to be preserved somewhere, on every frame, for the whole life of the page. On a full-screen canvas at device pixel ratio 2 that is a multi-megabyte copy sixty times a second.

Duimstok is a first-person walkthrough. It is rendering continuously while you move through a house, and frame time is the budget that decides whether walking feels like walking. Photography is a key someone presses maybe once a minute. Taxing every frame of the shipped application to make one keystroke convenient is a bad exchange rate, and it is invisible in exactly the way that lets it survive review.

The flag is not wrong. It is just priced per frame and paid by everyone, including everyone who never takes a photo.

Reading the frame back in the same task

There is another way to read the buffer, and it follows from the same rule that breaks the naive version. The buffer is invalidated once the browser composites — and the browser cannot composite in the middle of your function. Render and read in one uninterrupted task and the pixels are still there.

This is the whole of takePhoto():

export function takePhoto(world: World, opts: PhotoOptions = {}): Photo {
  const { renderer, scene, camera } = world;
  const canvas = renderer.domElement;

  const savedPosition = camera.position.clone();
  const savedQuaternion = camera.quaternion.clone();
  const savedRatio = renderer.getPixelRatio();
  const size = renderer.getSize(new THREE.Vector2());

  try {
    if (opts.at) aimCamera(camera, opts.at, opts.heading ?? 0);
    else if (opts.heading !== undefined) {
      aimCamera(camera, [camera.position.x, camera.position.y, camera.position.z], opts.heading);
    }

    opts.prepare?.();

    const ratio = supersampleRatio(savedRatio, size, opts.scale ?? 1);
    if (ratio !== savedRatio) {
      renderer.setPixelRatio(ratio);
      // The logical size is unchanged and `updateStyle` is false, so only the
      // drawing buffer grows: the aspect — and therefore the framing — is exactly
      // what was on screen.
      renderer.setSize(size.x, size.y, false);
    }

    renderer.render(scene, camera);
    return {
      dataUrl: canvas.toDataURL('image/png'),
      width: canvas.width,
      height: canvas.height,
    };
  } finally {
    if (renderer.getPixelRatio() !== savedRatio) {
      renderer.setPixelRatio(savedRatio);
      renderer.setSize(size.x, size.y, false);
    }
    camera.position.copy(savedPosition);
    camera.quaternion.copy(savedQuaternion);
    camera.updateMatrixWorld();
    renderer.render(scene, camera);
  }
}

Aim, render, read, put everything back. There is no await anywhere in it and that is not an oversight — it is the invariant. The camera, the pixel ratio and the buffer size are all restored in the finally, and an honest frame is drawn afterwards, so a photo taken mid-walk cannot leave the view parked or the renderer at four times the size.

What the rule drags along with it

One constraint, several consequences that look arbitrary until you know it.

The conversion to a Blob is done by hand, decoding the data URL with atob, rather than by calling canvas.toBlob(). toBlob is the better API in every respect except the one that matters: it hands the result back in a callback, and by the time that callback runs the browser may have composited and thrown the buffer away.

Nothing in the path is asynchronous, all the way down. Any await introduced anywhere between the render and the read will produce a blank image, and it will do it intermittently, because whether the compositor got a turn depends on timing you do not control.

And a photo taken one tick later comes back empty. That failure reads as "capture is broken" rather than as "capture was moved out of its task", which is why the rule is written at the top of the file rather than left to be inferred from the absence of a flag. An absent flag looks like an oversight. Someone will eventually add it to fix a bug that is really a refactor.

Supersampling, and the 8192-pixel ceiling

Since the render is happening anyway, it may as well happen larger. Passing a scale raises the renderer's pixel ratio for that one frame, so only the drawing buffer grows. The logical size is unchanged and updateStyle is false, which means the aspect ratio — and therefore the framing — is exactly what was on screen. A 1600-pixel-wide window at device pixel ratio 2 and scale: 2 produces a 6400-pixel frame.

A close crop of a rendered floor lamp at full capture resolution: a thin
                black pole, a curved shade, a soft warm gradient on the wall behind it and
                the crisp mullion of a window to the right.
A slice of the picture at the top of this page, at close to its captured size. A lamp pole a couple of pixels wide is where the extra samples show.

The clamp matters more than the feature. A capture larger than the GL implementation's maximum renderbuffer does not throw — it silently produces a black or truncated frame. So the scale is clamped to 3×, and the resulting buffer is clamped again against 8192 pixels on the longest side, which is the size every WebGL2 implementation is required to support. A limit you discover by getting a black image back is not a limit, it is a trap.

Why captures are never saved to localStorage

Photos live in an in-memory gallery. They do not survive a reload, the panel says so on every visit, and Download is the only way one outlives the tab.

The obvious place to put them is localStorage, next to the layout autosave that is already there. It is the wrong place twice over. A single 2500×1500 PNG is larger than the whole ~5 MB origin quota on its own, so the first photo would throw. Worse, depending on the browser, the pressure of that write can evict what is already stored — which here is the furniture you spent twenty minutes arranging.

A photo gallery that survives reload is a nice feature. A photo gallery that silently deletes the thing the application is for is not a trade anyone would take if it were written down, which is the argument for writing it down. The gallery caps itself and revokes the object URLs it evicts, and when someone eventually asks for persistence the answer is IndexedDB, whose per-origin quota is measured in hundreds of megabytes.

Recording video, and the Safari problem

Video is MediaRecorder over canvas.captureStream(), which is a much easier problem than the still — the stream pulls frames itself and the same-task rule does not apply.

The container and codec are negotiated at record time rather than assumed, best first: VP9, then VP8, then bare WebM for a browser that supports the container but will not commit to a codec, then MP4. That last entry is Safari, which records H.264 and nothing else. The file extension is derived from the recorder's own MIME type rather than hardcoded, because guessing .webm and writing an MP4 produces a file that some players will open and others will refuse.

One more failure worth naming: a hidden tab draws no frames, so captureStream hands over nothing and you get a zero-byte file. That is easy to hit from a script, since driving a browser usually means not looking at it. Duimstok checks and says "the page was hidden, so the canvas drew no frames" rather than writing an empty file and calling it a clip.

When to just set the flag

Set preserveDrawingBuffer: true and stop reading. It is the right answer whenever the per-frame copy is not a real cost: a scene that renders on demand rather than continuously, a small canvas, a page that is not competing for frame time with anything. It is also the right answer when captures have to be taken from code you do not control, or from a callback you cannot make synchronous, because the same-task rule is a constraint on every future caller and not just on the one you are writing now.

The same-task read is worth it when you are rendering continuously, when frame time is the budget that decides how the product feels, and when captures are rare. Those three things are true of a walkthrough and false of a great many other WebGL applications.

What generalises is neither of the two answers. It is that the well-known fix to a problem is often a global setting paying for a local convenience, and the price is usually quoted per frame while the benefit is quoted per keystroke. Worth checking which one you are buying.