ScreenshotNeo

BlogHow-to

How to Capture WebGL Without a GPU

Render WebGL in headless Chromium with CPU-based SwiftShader, wait for the scene to finish, then capture and verify the screenshot with Puppeteer.

By the ScreenshotNeo team4 October 202611 min read

Yes—WebGL can be rendered without a physical GPU. Chromium can use SwiftShader, a software renderer that runs graphics work on the CPU. For a repeatable capture, launch Chromium with the documented SwiftShader WebGL flags, navigate to the page, wait for the application’s own ready signal, verify that its WebGL canvas rendered, and save a screenshot with Puppeteer.

There is an important limitation: Chromium documents the SwiftShader WebGL fallback as an unsafe opt-in with reduced security guarantees. Use it for trusted content and testing, not as a general-purpose way to browse untrusted sites. WebGL availability is not guaranteed, so detect failures instead of accepting a blank image. See Chromium’s SwiftShader guide.

1. What “without a GPU” means

SwiftShader implements graphics APIs in software and runs them on the CPU. Chromium documents two distinct ways of using it: as an OpenGL ES driver, or as a WebGL fallback. These paths are related but not interchangeable.

Path Chromium flags What it means
SwiftShader as OpenGL ES driver --use-gl=angle --use-angle=swiftshader Chromium uses SwiftShader as its OpenGL ES driver.
SwiftShader as WebGL fallback --use-gl=angle --use-angle=swiftshader-webgl --enable-unsafe-swiftshader Chromium explicitly opts in to SwiftShader for WebGL fallback.

For capturing a page that needs WebGL on a machine without a supported GPU, use the second, explicitly opted-in route. Chromium says automatic fallback to SwiftShader-backed WebGL is deprecated. It warns that the opt-in lowers security guarantees because JIT-compiled code runs in Chromium’s GPU process, and says this mode is not intended for untrusted content.

Headless mode is a way to run Chrome without visible UI; it does not by itself mean that a physical GPU is required. Likewise, --disable-gpu alone is not a guarantee that WebGL will work in every Chromium build and environment. Keep the SwiftShader flags explicit, and verify the resulting context and image.

2. Install Puppeteer and run a capture

The example below uses Node.js and Puppeteer. It captures a trusted page at a fixed viewport, waits for an application-provided readiness marker, checks that the WebGL canvas has a context, and writes a PNG file.

  1. Install a current Node.js release.
  2. Create a project and install Puppeteer, which downloads a compatible browser as part of its normal installation.
  3. Save the script as capture-webgl.mjs and replace the example URL and readiness selector with those for your application.
  4. Run it with node capture-webgl.mjs.
npm init -y
npm install puppeteer
import puppeteer from 'puppeteer';

const target = 'https://example.com/webgl-demo';
const output = 'webgl-capture.png';

const browser = await puppeteer.launch({
  headless: true,
  args: [
    '--use-gl=angle',
    '--use-angle=swiftshader-webgl',
    '--enable-unsafe-swiftshader',
  ],
});

try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1440, height: 1000, deviceScaleFactor: 1 });
  page.setDefaultNavigationTimeout(60000);
  page.setDefaultTimeout(30000);

  page.on('console', message => {
    if (message.type() === 'error') console.error('Page console:', message.text());
  });
  page.on('pageerror', error => console.error('Page error:', error.message));

  const response = await page.goto(target, { waitUntil: 'domcontentloaded' });
  if (!response || !response.ok()) {
    throw new Error(`Navigation failed: ${response?.status() ?? 'no response'}`);
  }

  // Replace this with a marker set by your application only after the desired
  // scene state has rendered, for example: data-webgl-ready="true".
  await page.waitForSelector('[data-webgl-ready="true"]', { timeout: 30000 });

  const canvasStatus = await page.evaluate(() => {
    const canvas = document.querySelector('canvas');
    if (!(canvas instanceof HTMLCanvasElement)) return { ok: false, reason: 'No canvas found' };
    const gl = canvas.getContext('webgl2') || canvas.getContext('webgl') ||
      canvas.getContext('experimental-webgl');
    if (!gl) return { ok: false, reason: 'WebGL context unavailable' };
    if (canvas.width === 0 || canvas.height === 0) {
      return { ok: false, reason: 'Canvas has zero dimensions' };
    }
    return { ok: true, width: canvas.width, height: canvas.height };
  });

  if (!canvasStatus.ok) throw new Error(canvasStatus.reason);

  await page.screenshot({ path: output, type: 'png' });
  console.log(`Saved ${output}; canvas ${canvasStatus.width}×${canvasStatus.height}`);
} finally {
  await browser.close();
}

The selector in this example is deliberately application-specific. Add a marker after your renderer has loaded its assets and drawn the intended scene. If the application exposes a promise or state value instead, wait for that with page.waitForFunction(). Puppeteer’s selector wait only confirms that a DOM element exists; it cannot prove that the canvas contains the correct final frame. Puppeteer documents page screenshots through Page.screenshot() and element screenshots through ElementHandle.screenshot().

3. Wait for the rendered frame, not merely page load

A navigation event such as domcontentloaded says the document was parsed. It does not mean the WebGL app initialized, loaded textures, completed asynchronous data requests, or rendered the state you want. Even a network-idle signal can be a poor readiness test for pages with persistent connections or delayed scene work.

Prefer an application-owned readiness marker

If you control the page, set a marker only after the scene is ready. For example, your application can update a data attribute after model and texture loading completes and after its render loop has produced a frame:

// In the WebGL application, after assets load and the target scene is drawn:
document.documentElement.dataset.webglReady = 'true';

Then wait for html[data-webgl-ready="true"] in Puppeteer. A selector that appears before rendering starts is not sufficient.

When you cannot change the page

  • Wait for a known DOM element that appears only when the scene is initialized, then allow a short, bounded delay for the next animation frame.
  • Use a page-specific condition with waitForFunction() if the app exposes a stable global status or scene value.
  • Inspect the canvas dimensions and WebGL context, then validate the saved image. Context creation alone does not prove that the desired scene rendered.

A fixed delay is a fallback, not a reliable readiness contract: it may be wastefully long on a fast run and too short under load. For animation, decide which frame matters and arrange for the page to pause or expose that state before capture.

4. Choose what to capture

Use a viewport screenshot when the result should match what a user sees at a particular screen size. Set the viewport before navigation if responsive layout affects scene setup. Puppeteer’s page.screenshot() captures the page; fullPage: true extends the screenshot to the full document, which can be useful for surrounding page content but does not turn a long canvas into a higher-resolution 3D render.

// Viewport screenshot
await page.screenshot({ path: 'viewport.png', type: 'png' });

// Full document screenshot, including content beyond the viewport
await page.screenshot({ path: 'full-page.png', type: 'png', fullPage: true });

// Capture just the canvas element
const canvas = await page.waitForSelector('canvas');
await canvas.screenshot({ path: 'canvas.png', type: 'png' });

Element screenshots are useful when the page has controls or margins you do not want in the output. Keep in mind that an element screenshot captures its displayed pixels; it does not extract a higher quality image from the WebGL drawing buffer.

5. Other browser automation options

The rendering requirement is Chromium’s SwiftShader configuration; screenshot APIs are the final capture step. The complete runnable example above uses Puppeteer. If your automation stack is Python or you need a quick command-line capture, you can connect to a Chromium process launched with the same flags.

Python with Playwright

Install Playwright and its Chromium browser. Save as capture_webgl.py. This launches Chromium with the SwiftShader WebGL opt-in and waits for your app’s marker before saving:

python -m pip install playwright
python -m playwright install chromium
import asyncio
from pathlib import Path
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True, args=[
            '--use-gl=angle',
            '--use-angle=swiftshader-webgl',
            '--enable-unsafe-swiftshader',
        ])
        page = await browser.new_page(viewport={"width": 1440, "height": 1000})
        page.set_default_navigation_timeout(60000)
        page.set_default_timeout(30000)
        response = await page.goto('https://example.com/webgl-demo', wait_until='domcontentloaded')
        if response is None or not response.ok:
            raise RuntimeError(f'Navigation failed: {response.status if response else "no response"}')
        await page.locator('[data-webgl-ready="true"]').wait_for(state='attached')
        status = await page.locator('canvas').evaluate('''canvas => {
          const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
          return !!gl && canvas.width > 0 && canvas.height > 0;
        }''')
        if not status:
            raise RuntimeError('Canvas is missing, zero-sized, or has no WebGL context')
        await page.screenshot(path='webgl-capture.png')
        await browser.close()

asyncio.run(main())

Replace the URL and marker as in the Node example. The Python code waits for context creation and nonzero canvas dimensions; add an app-owned visual or state check if you need to verify a particular frame.

cURL

cURL does not render web pages or create a WebGL context. It can only request a screenshot from a service that performs browser rendering. To run the no-GPU Chromium workflow locally, use a browser automation library as above. For a hosted one-request option, see ScreenshotNeo below.

6. Verify the output and handle failure

  • Check the browser logs. Record console errors and page exceptions. A page can return HTTP 200 while its WebGL initialization fails.
  • Check context creation. Test webgl2 and then webgl as appropriate for the application. Handle null by failing the capture or using the app’s own Canvas2D fallback.
  • Check the captured pixels. A valid context and nonzero canvas can still produce a blank, stale, or incomplete frame. Inspect or automatically validate the actual image for your workload.
  • Keep failures visible. Do not silently publish a blank screenshot as success. Return a specific failure reason and retain useful logs.

Chromium explicitly notes that it and other browsers do not guarantee WebGL availability. If you own the WebGL application, offer Canvas2D or a clear message when context creation fails.

7. Performance, reliability, and cost

Performance

SwiftShader moves graphics work to the CPU, so capture time depends on scene complexity, resolution, asset loading, and available CPU. Do not assume it will match hardware rendering speed; the Chromium sources do not give a general benchmark number. Start with the output dimensions you need, avoid rendering unnecessarily large scenes, and measure your own representative pages.

Reliability

  • Pin the browser version and automation dependency in repeatable jobs, and keep the launch flags explicit.
  • Use an application readiness condition and a bounded timeout. Retry transient navigation or asset failures only when the retry is safe.
  • Record the URL, browser errors, readiness outcome, and whether WebGL context creation succeeded alongside the resulting file.
  • Use trusted pages with the unsafe SwiftShader opt-in. The lower security guarantee is material when the browser can load attacker-controlled content.

Cost

A local setup uses CPU time and the resources needed to run Chromium; this workflow does not require buying a GPU. Hosted browser infrastructure may have its own costs, so account for browser runtime, concurrency, storage, and retries in your environment. A failed or blank render still consumes your own compute unless your capture service handles billing differently.

8. Common errors and fixes

Symptom Likely cause What to do
getContext('webgl') returns null WebGL is unavailable, flags were not passed to the launched Chromium process, or the page/browser combination cannot create a context. Confirm the process uses --use-gl=angle --use-angle=swiftshader-webgl --enable-unsafe-swiftshader. Check browser logs and fail visibly if the context still cannot be created.
Canvas is blank although navigation succeeded Page load completed before app initialization or rendering; assets may have failed. Wait for an app-owned readiness signal, inspect console and network failures, and validate the image instead of treating navigation success as render success.
SwiftShader warning or WebGL initialization failure Automatic software fallback is deprecated, or the explicit opt-in is missing. For trusted test content, use the documented unsafe WebGL fallback flags. Do not remove the opt-in warning by assuming fallback is guaranteed.
Capture times out waiting for selector The selector is wrong, appears in another frame, or the app never reaches the expected state. Check the selector in the page, account for iframes, and expose a marker that reflects actual scene readiness. Keep a finite timeout and log the failure.
Screenshot captures an old animation frame The app is still animating or the readiness marker is set too early. Signal readiness after the desired frame is drawn; pause or control animation if the output must be deterministic.
Screenshot is cropped or unexpectedly large Viewport, device scale factor, full-page mode, or element bounds differ from the desired output. Set viewport and scale factor explicitly; choose viewport, full-page, or element capture intentionally.
--disable-gpu makes no difference That switch alone does not select the documented SwiftShader WebGL fallback route. Use Chromium’s specific SwiftShader flags and test on the actual browser build you deploy.

9. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. A single GET request can return an image or PDF. It is useful when you want a hosted page capture without installing and configuring a local browser. For a WebGL page, confirm the rendered output suits your use case; the API call does not replace the page-specific verification described above.

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/webgl-demo -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com/webgl-demo"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.com/webgl-demo'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.

10. FAQ

Can I use SwiftShader for arbitrary public URLs?

Chromium says the unsafe WebGL opt-in has lower security guarantees and is not intended for untrusted content. Restrict this configuration to content you trust.

Does a successful WebGL context mean my screenshot is correct?

No. It confirms that a context exists, not that the application rendered the intended scene or frame. Wait for application readiness and inspect the resulting capture.

Should I enable headless hardware acceleration instead?

That is a different goal. This guide uses CPU rendering for a no-GPU setup. Headless hardware acceleration depends on platform and driver configuration; consult the Chrome Headless documentation for that separate setup.

Can a screenshot API capture a particular WebGL frame?

A URL-based capture takes a screenshot of the page state it reaches. If a particular animation frame or app state matters, arrange for the page to expose that state before capture and verify the returned image.