How to Fix Three.js canvas.toDataURL() in Chromium
Fix blank or black Three.js canvas exports in Chromium with immediate rendering, preserved buffers, render targets, and practical diagnostics.
If canvas.toDataURL() returns a blank or black image while your Three.js scene looks correct on screen, render the scene again immediately before reading the canvas. Chromium normally clears the WebGL drawing buffer after it has been presented unless preservation was requested.
function render() {
// Update scene and camera state before this call if needed.
renderer.render(scene, camera);
}
function capture() {
render();
canvas.toBlob((blob) => {
if (!blob) return;
// Save or upload the PNG blob.
}, 'image/png');
}
This is the preferred one-off capture pattern described in the Three.js manual. If you must capture later without rendering again, create the WebGL context with preserveDrawingBuffer: true. For repeated or explicit pixel readback, render into a WebGLRenderTarget.
Why the export is blank in Chromium
A WebGL canvas has a drawing buffer. After the browser composites that buffer to the page, the buffer may be cleared before your later JavaScript call. With the default preserveDrawingBuffer: false, a visible frame is therefore not guaranteed to remain available for toDataURL().
Animation can hide the timing bug: the render loop draws one frame, Chromium presents it, and a click handler later reads a buffer that has already been cleared. Rendering synchronously in the capture function makes the frame you read the frame you intended to export.
Choose a capture strategy
| Situation | Recommended approach | Trade-off |
|---|---|---|
| One screenshot now | Render immediately, then call toBlob() or toDataURL() |
Smallest change; no persistent buffer |
| Capture after an unrelated event | Create the context with preserveDrawingBuffer: true |
Can reduce performance and memory efficiency |
| Recurring exports or pixel processing | Render to a WebGLRenderTarget and read it |
More code; explicit image encoding required |
Immediate re-render: the simplest fix
Keep scene updates separate from your animation scheduler. Set the exact camera, controls, animation time, and visibility state you want, render once, then export.
const canvas = document.querySelector('#scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 5);
function renderScene() {
renderer.render(scene, camera);
}
function downloadPng() {
// Apply deterministic state here.
// controls.update();
// mixer.setTime(2.5);
renderScene();
canvas.toBlob((blob) => {
if (!blob) throw new Error('Canvas encoding returned no Blob');
const link = document.createElement('a');
link.download = 'threejs-capture.png';
link.href = URL.createObjectURL(blob);
link.click();
URL.revokeObjectURL(link.href);
}, 'image/png');
}
toBlob() avoids keeping a large base64 string in JavaScript memory and is generally the better export API. Use toDataURL() when an immediate data URL is specifically required:
renderScene();
const dataUrl = canvas.toDataURL('image/png');
Preserve the drawing buffer for delayed capture
Set the attribute when the context is first created:
const renderer = new THREE.WebGLRenderer({
canvas,
preserveDrawingBuffer: true
});
The WebGL specification warns that preserving the drawing buffer can cause significant performance loss on some platforms. Leave it disabled when immediate rendering or a render target meets your needs.
If you create the context yourself, configure the attribute on getContext() before passing that context to Three.js:
const gl = canvas.getContext('webgl2', {
preserveDrawingBuffer: true,
alpha: true,
premultipliedAlpha: true
});
const renderer = new THREE.WebGLRenderer({ canvas, context: gl });
Renderer options cannot retroactively change attributes on an already-created context. This is a common reason developers report that preserveDrawingBuffer appears ineffective.
Resizing the canvas can clear its drawing buffer. After changing drawing-buffer dimensions, update the camera aspect and render again before exporting.
Render to an off-screen target
A render target gives you an explicit framebuffer instead of relying on the canvas’s presented frame. Three.js exposes synchronous and asynchronous pixel-read APIs and recommends the asynchronous form where possible.
const target = new THREE.WebGLRenderTarget(1024, 1024, {
format: THREE.RGBAFormat,
type: THREE.UnsignedByteType
});
renderer.setRenderTarget(target);
renderer.render(scene, camera);
renderer.setRenderTarget(null);
const pixels = new Uint8Array(1024 * 1024 * 4);
await renderer.readRenderTargetPixelsAsync(
target,
0,
0,
1024,
1024,
pixels
);
// Convert pixels to an image with your preferred encoder.
Remember that WebGL pixel coordinates start at the bottom-left, so an image encoder may require a vertical flip. Reading pixels is also a synchronization point; keep the target size and read frequency as small as your output allows.
Alpha, transparency, and dark edges
A transparent or dark-looking result is different from a buffer that was cleared. Check these independently:
alphacontrols whether the context has an alpha channel.premultipliedAlphacontrols how color channels relate to alpha.scene.backgroundand the renderer clear color determine opaque background pixels.- The WebGL conversion to encoded image data can make demultiplication lossy.
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
premultipliedAlpha: false
});
renderer.setClearColor(0x000000, 0);
These settings do not repair a drawing buffer that was already cleared. Render immediately before export first.
Canvas size versus CSS size
The exported pixel dimensions come from the drawing buffer, not necessarily the CSS dimensions. Set the renderer size and pixel ratio deliberately, then render again:
const width = 1200;
const height = 800;
renderer.setPixelRatio(1);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
const image = canvas.toDataURL('image/png');
A high device pixel ratio increases output dimensions and readback cost. Use the pixel dimensions your export actually needs.
Chromium troubleshooting checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Blank or black image; page looks correct | Buffer was cleared before readback | Render synchronously immediately before export |
preserveDrawingBuffer has no effect |
Context was created first with different attributes | Pass the attribute to getContext() or let Three.js create the context |
| Image changes after resize | Resize cleared the drawing buffer or changed projection | Resize, update the camera, then render again |
| Transparent background is black | Alpha or clear color configuration | Check alpha, clear alpha, and premultiplication separately |
| Colored fringe around transparent objects | Premultiplied and straight-alpha mismatch | Make renderer, textures, and encoder use compatible alpha assumptions |
SecurityError from toDataURL() |
Canvas is not origin-clean | Inspect cross-origin assets and how they are served; this is distinct from buffer clearing |
| Readback is slow | Large synchronous GPU-to-CPU transfer | Reduce target size or use readRenderTargetPixelsAsync() |
| Different output on different machines | GPU, Chromium, Three.js, context, or color differences | Record versions, OS, GPU, context attributes, and a minimal reproduction |
Reliability and performance guidance
- For a single image, render immediately before capture and keep the default transient buffer.
- Use
preserveDrawingBufferonly when delayed access is required and the performance cost is acceptable. - Use a render target when capture is part of a pipeline, when the canvas is continuously presented, or when you need controlled dimensions.
- Prefer
toBlob()overtoDataURL()for large images to avoid a large base64 string. - Do not capture until required fonts, textures, and asynchronous scene updates are ready.
- For a suspected Chromium-specific issue, record Chromium version, operating system, GPU, Three.js version, context type, context attributes, exact exception, and output image.
Or skip the browser setup
If you need a clean screenshot of a rendered page rather than an in-app WebGL readback, ScreenshotNeo provides a GET screenshot API and MCP server. It removes cookie banners, newsletter popups, and chat widgets before capture; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed. Responses identify the page verdict and billing status with X-Page-Verdict and X-Billed headers. Its MCP tools let Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.
Read the complete parameter list in the ScreenshotNeo API documentation.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com \
-o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
await require('node:fs').promises.writeFile('shot.webp', image);
The free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots, and every feature is available on every plan. Create a free ScreenshotNeo account.
FAQ
Should I always enable preserveDrawingBuffer?
No. Start with an immediate re-render. Preservation is useful for delayed reads but can reduce performance.
Does toBlob() solve a cleared WebGL buffer?
No. Render immediately before either export method. toBlob() mainly avoids the memory cost of a base64 data URL.
Is a black image proof of a Chromium bug?
No. Check capture timing, context creation, resizing, alpha settings, and origin-clean errors before treating it as browser-specific.
When should I use a render target?
Use one when you need explicit off-screen rendering, repeatable dimensions, or controlled pixel readback.


