Skip to content

Repository files navigation

Open GPU Benchmarker

License: MIT

An open-source GPU benchmark that runs in the browser with nothing to install (opengpubenchmarker.com). It runs one benchmark, Neon Flythrough: a fixed 96 s flight through a dense, rain-soaked cyberpunk megacity. The camera dives between stacked towers to just above the street traffic, flies under sky bridges, weaves through the streets, rides between stacked sky-lanes of flying cars, and climbs over the rooftops for a skyline that runs out to the horizon. It reports Average FPS and 1% Low FPS, a 3DMark-style score on the two fixed score runs, a stability % on the two ~10-minute stress tests, and a diagnosis of what limited the run (GPU, CPU, driver/browser). You can keep results in your browser or submit them to Open GPU Benchmarks.

  • Vite + TypeScript + Three.js 0.186.1 on WebGL2 (WebGLRenderer, no WebGPU).
  • Everything is generated in code from a seeded PRNG. There are no downloaded models or textures.
  • Static site deployed on Cloudflare Workers Static Assets.

Run it

npm install
npm run dev        # local dev server
npm test           # Vitest unit tests
npm run test:coverage   # unit tests + coverage report (coverage/)
npm run test:e2e        # Playwright browser tests in e2e/ (starts its own dev server)
npm run build      # type-check + production build into dist/
npm run preview    # serve dist/ locally
npm run deploy     # build + wrangler deploy (production)
npm run preview:deploy -- <name>  # build + upload a preview at <name>.opengpubenchmarker.com
npm run check:share     # after a build: every Share button must pass the link (Playwright)

Testing on a phone (LAN) and preview deploys

npx vite --host          # dev server on the LAN, e.g. http://192.168.1.20:5173
npx vite preview --host  # same for the production build in dist/

The Web Share API and navigator.clipboard need a secure context (HTTPS or localhost). Over plain LAN HTTP they are unavailable, so on a phone use LAN only for layout, run flow and results rendering; test share/clipboard paths on localhost or on a preview deploy:

npm run preview:deploy -- stress-tests   # after `npm test` and `npm run build` pass locally

That runs wrangler preview --name stress-tests (Worker Previews; previews = {} and previews_enabled = true on the route in wrangler.toml). Name each preview after its feature branch. The Preview is served at https://stress-tests.opengpubenchmarker.com (the custom domain is set to "Production and Preview") and at https://stress-tests-openbenchmarker.mattys.workers.dev. A Preview never touches production: opengpubenchmarker.com changes only on npm run deploy. Previews are public, like the site itself; public/_headers marks preview subdomains noindex. A Preview is a different origin, so it has its own localStorage and does not see results saved on production. Remove it with npx wrangler preview delete --name stress-tests. See docs/privacy-audit.md for what the site does and does not store or send.

Sharing. A valid result can be shared as a link (#/r/<payload>) plus a share card PNG with a QR code. The whole result is encoded in the URL fragment, which browsers never send to the server; it is encoded, not encrypted, and sharing runs on the honor system (nothing is signed or verified). Format and test steps: docs/share-links.md.

Phones can run out of memory at 1440p and 4K on heavier presets, and the OS then closes the page. The site detects this on the next load, offers a lower resolution, and asks for a one-tap confirmation before memory-heavy runs on phones and tablets. See docs/high-res-memory.md for the estimates and docs/device-test-matrix.md for the device checklist.

To deploy, run npx wrangler login once, then npm run deploy. wrangler.toml serves ./dist with an SPA fallback. Attach the custom domain opengpubenchmarker.com in the Cloudflare dashboard (Workers → openbenchmarker → Settings → Domains).

Score runs

There are two fixed tests, like 3DMark's. Their settings are locked, so every score measures the same workload:

Score run Preset Resolution Duration
Score: Entry Low 720p (1280×720) 60 s
Score: Extreme Extreme 4K (3840×2160) 60 s

Score = round(Average FPS × 100). The score is linear in FPS, so any gain, from a faster GPU or an overclock, raises it. FPS, the 1%/5% lows and the diagnosis are shown next to it.

  • FPS goes up with a faster GPU because nothing caps it. Frames render back-to-back with no vsync or requestAnimationFrame wait, and Avg FPS is frames ÷ wall-clock time.
  • Scene time follows wall-clock time, so the duration decides which part of the flight is timed. That's why score runs lock the duration as well.
  • A score is only given to a valid run started from a score-run button whose settings match exactly. Custom runs report FPS only.
  • A CPU-limited run still gets its score. It is never stopped or hidden, but it is labelled, and the diagnosis says what held it back. Score: Entry is often CPU-limited on fast GPUs. Score: Extreme is meant to be GPU-limited.

Stress tests

Two long locked runs for sustained performance (thermal throttling, power limits), like 3DMark's stress tests. They use the score runs' settings and repeat the whole 96 s flight as laps:

Stress test Preset Resolution Laps Timed
Stress: Entry Low 720p (1280×720) 6 576 s ≈ 10 min
Stress: Extreme Extreme 4K (3840×2160) 6 576 s ≈ 10 min
  • Every lap is the same workload. Scene time wraps every 96 s on stress runs (t mod 96), so traffic, drones and rain repeat exactly and lap 6 renders the same frames as lap 1. Other runs end before 96 s of scene time, so the wrap never applies to them.
  • Stability = slowest lap avg FPS ÷ fastest lap avg FPS, the 3DMark definition. 97% or more is "steady" (3DMark's pass mark), 90–97% "slowed a little", below 90% "throttled". The results page also shows the average over all laps, first → last lap, a lap chart and a lap table (avg FPS and 1% low per lap).
  • Two tests, not one. Extreme 4K is what fully loads a desktop GPU long enough to heat it; Low 720p is the setting phones and iGPUs can hold for 10 minutes (uncapped, it still fully loads a phone's chip). A single middle setting would be too light for one and too heavy for the other. Stress: Entry on a fast desktop GPU is usually CPU-limited, so a drop there points at the CPU; the reading says so.
  • A browser can't read temperatures or clocks, so the verdict comes from frame rates alone.
  • Stress runs are saved, shared (the link carries every lap, see docs/share-links.md) and compared like other runs, but never submitted to Open GPU Benchmarks: their average includes throttled laps. "Copy run report (YAML)" gives the same details for pasting.
  • The run keeps the screen awake (Screen Wake Lock API, every run), and the crash record notes each finished lap, so a phone that shuts the page down from heat says how far it got.
  • Dev server only: ?laps=N runs a stress test with N laps for testing; the result doesn't match the locked test, so it has no stability and can't be shared.

What limited this run (diagnostics)

During the run, the HUD shows a live LIMIT chip (GPU / CPU / GPU + CPU, with GPU busy %), plus the GPU and JavaScript time per frame. The results page explains the limit in plain words and lists fixes. All of it comes from signals the browser exposes (src/benchmark/diagnose.ts):

Signal How it's measured
GPU busy % / GPU ms per frame EXT_disjoint_timer_query_webgl2 time per frame ÷ wall-clock
JS ms per frame (scene update vs draw submission) performance.now() around each frame, and around the scene update
GPU wait per frame Time spent waiting with the GPU queue full (three batches queued)
Browser overhead Share of wall-clock outside render batches (1 − loop busy)
Main-thread stalls Yields delayed > 20 ms by other work (tabs, extensions, GC)
Hitches, frame-time spread Frames > 2.5× the median and at least 4 ms over it; standard deviation
JS heap growth performance.memory (Chromium only)
Renderer, ANGLE backend, max texture size WEBGL_debug_renderer_info and gl.getParameter
Battery Battery Status API (Chromium), at the start and when timing begins

The verdicts:

  • GPU-limited when the GPU is busy ≥ 90%.
  • Mixed at 75–90%.
  • Below that it is CPU-limited when the page's JavaScript or browser overhead dominates, or Driver / browser-limited when JS is idle but each batch still waits for the GPU. That wait is CPU work in the browser's GPU process and the driver.
  • Without GPU timers (Firefox, Safari), the verdict is estimated from the GPU wait and marked "est.".

The diagnosis also flags:

  • software rendering (results can't be submitted)
  • a likely integrated GPU
  • running on battery
  • stalls
  • uneven frame pacing
  • preset features that fell back

How a run works

  1. Before the run you choose one of four standard resolutions: 720p (1280×720, a lighter option for lower-end GPUs), 1080p, 1440p or 4K (3840×2160), recorded with upstream's names. Only these four can be submitted (there is no Native option, so results are always comparable across machines). The scene renders at exactly that size with setPixelRatio(1), and CSS only scales it to fit on screen. You also choose a preset (Low to Extreme) and a duration (30/60/90 s). These settings can't change during the run.
  2. The run view appears first. The tool then measures the stage and only then creates the WebGL context, builds the city, and renders one frame right away so any setup failure shows up at once.
  3. A 3 s warm-up follows. It is never counted.
  4. The timed phase follows. Frames render back-to-back in batches of about 8 ms, chained over a MessageChannel. Batches don't wait for vsync, so the score can go well above the monitor's refresh rate. The browser only shows the canvas between tasks (a batch's last frame), so batches are kept short for smooth motion on screen; 2.1.0's 28 ms batches showed only ~35 updates a second however high the FPS.
    • Pipelined (WebGL2): each batch ends with a fence, and three batches are kept queued on the GPU. When one finishes, the next is already waiting, so the GPU doesn't idle while the page updates the HUD, yields, or builds the next frame. While the queue is full, the loop sleeps until shortly before the oldest batch is due, instead of pinning a CPU core.
    • Batch intervals: a batch's interval runs from the previous batch's completion to its own.
    • Phase boundaries: the queue is drained with a 1-pixel readPixels at the warm-up→timed switch and at the end, so timing starts and stops on an idle GPU. Chrome's gl.finish() doesn't actually wait, so it isn't used. Warm-up/timed membership is fixed when a frame is submitted.
    • WebGL1: it has no fences, so every batch is drained with a readback.
    • Pre-roll: while the city is built, every material is compiled and frames are rendered at 16 points across the whole flight, so shader compiles and texture uploads happen before the warm-up rather than as hitches mid-run (a cold shader cache, e.g. the first run after a browser update, used to stutter).
    • Before 2.1.0, every batch was drained, which left the GPU idle for part of each batch (about 3–15% depending on the settings).
  5. Stats: Avg FPS = timed frames / timed wall-clock (throughput). 1% Low (p1_low) = mean FPS of the slowest 1% of frames, using per-frame times. When EXT_disjoint_timer_query_webgl2 is available, each frame's time is its GPU time from a timer query, or its batch's average frame time if that is larger (CPU-bound); results flagged disjoint are dropped. Without the extension, every frame gets its batch's average frame time. The method is recorded as timing (gpu-timer or batch-avg) in JSON/CSV. The tool also records 5% low, worst frame, and frame count. Scores from 1.1.0 aren't comparable with 1.0.0, which synced after every frame. 2.1.0 keeps the GPU queue full (see step 4), so GPU-limited results can be a few percent higher than on 2.0.0 with the same hardware. Scores from 2.0.0 aren't comparable with 1.x either: 2.0.0 is a new scene and new presets (a much heavier workload). The measurement itself is unchanged. My Results shows each result's version.
  6. Validity: these mark the run INVALID and end it immediately: hiding the tab, resizing the window or stage, aborting, losing the WebGL context, or a render error. An invalid run can be saved locally but can never be submitted.

The HUD sits inside the stage, below the top banner. The banner stays in normal page flow (never fixed or sticky), so the HUD can't end up hidden under it.

Presets

Preset Towers Detail Rain Signs Cars Lane levels Drones Far drones Holos Skyline Clouds Shafts Bloom Shadow map MSAA Reflections Haze steps
Low 260 0 3 000 120 90 2 8 120 2 1 1 2 0.4 – – – –
Medium 520 1 5 000 260 200 3 16 300 4 2 1 4 0.55 1024 8 – –
High 900 2 8 000 500 400 4 28 700 6 3 2 8 0.6 2048 8 – 6
Ultra 1 600 3 24 000 1 100 900 5 70 2 000 14 4 3 14 0.7 2048 8 1.0× 12
Extreme 2 700 3 330 000 2 600 2 200 5 260 6 000 40 4 3 24 0.7 8192 8 1.0× 24
  • Towers get the full archetype treatment (slab, tiered setback, podium + tower, round/octagonal, megastructure) on the lots nearest the flight; every other lot of the 32×32-block grid gets a plain window-shaded box, so every preset has a city all the way out. Detail (0-3) adds ledges, rooftop tanks/AC/sheds, antenna masts with spires and beacons, shop awnings and lanterns, facade AC units, pipes, ribs, balconies and scaffolding. Detail 0 also uses simpler windows (no interior parallax or flicker).
  • Lane levels: traffic altitudes in use (street, then sky-lanes at 50, 25, 80, 120).
  • Skyline: horizon rings of window-dotted silhouettes. Clouds: low cloud layers lit from below. Shafts: rooftop searchlight beams, plus 3× as many light spills under the biggest signs. Haze steps: volumetric haze (src/engine/volumetric.ts), a per-pixel ray march through drifting 3D noise that picks up light from the nearest neon signs. It is GPU-only work that scales with pixels × steps.
  • The shadow map is capped at the GPU's MAX_TEXTURE_SIZE (Extreme asks for 8192); a capped run says so and isn't comparable.
  • 2.2.0 rebalanced the presets to load fast GPUs about twice as hard (target on an RTX 5080 at 4K: Extreme ~65 FPS, Ultra ~100, High ~150, Medium ~250; Low stays light enough for phones). Measured with the dev-only preset lab (/?lab on npm run dev).
  • Every field is non-decreasing from Low to Extreme (a test enforces it).

The streets are drawn procedurally on the ground plane on every preset, at no extra draw calls (src/engine/ground.ts). They have wet asphalt with puddles, neon lane markings that line up with the traffic lanes, pulsing holographic crosswalks, a guide grid over each junction, glowing curb strips with light spilling onto the road, paved sidewalks with floor lights, and drain grates.

Presets with bloom also add a grade pass (chromatic aberration, scanlines, grain, vignette). On Ultra and Extreme, a Reflector re-renders the whole scene mirrored every frame to make the wet-street reflections.

Load per preset at 1280×720 (t = 20 s, renderer.info summed over every pass of one frame, including shadow, reflection and post passes), v1.1.0 → v2.0.0:

Preset Draw calls v1 → v2 Triangles v1 → v2
Low 25 → 23 15 k → 92 k
Medium 52 → 52 24 k → 416 k
High 53 → 56 34 k → 1.16 M
Ultra 103 → 96 136 k → 4.14 M
Extreme 165 → 115 339 k → 7.47 M

Camera clearance. Nothing solid comes within 14 units (CLEAR_SOLID) of the camera path in 3D: every tower part, sky bridge, sign, lantern and filler box is an axis-aligned box checked against a grid index of the densely sampled path. A failing upper tier truncates its tower, a failing base shrinks the lot (or leaves a plaza), and failing details are dropped. Moving things keep 10 units (CLEAR_MOVING): each whole traffic lane and level-change column, every drone orbit, and every far-swarm ring. tests/scene.test.ts checks all of these for every preset.

Honest-data rules

This tool feeds a public dataset, so:

  • Measured by the run: FPS stats, resolution, preset, benchmark id and version, date, and the graphics API read from the WebGL context that was actually created (shown read-only on the results form).
  • Provided by the user: everything else. To make things easier, we sometimes suggest a value, our best guess, especially for details most people don't know offhand about their device. Suggested values are always marked, and you can change or clear them. We don't take liberties with your data: measured values come from the run, and anything else is yours to confirm. Fields start blank (example values appear only as grey placeholders) unless a suggestion applies. Today there is one: memory_channels is 4 on phone and tablet runs, because browsers can't detect it. It's listed in the result's suggested field, shown as "4 (assumed)" in My Results, exported in CSV (suggested_fields) and JSON, and commented # assumed, not confirmed in submission YAML. Editing the field removes the mark. Rules live in the field registry, src/benchmark/fields.ts.
  • Carried to the next run: a new desktop/laptop run starts with the details you filled in on your last result (carry: true in the registry: GPU, OS, OS version, CPU, memory, memory type and channels, GPU driver, distro, overclocked and the overclock details; not kernel, Mesa or Proton, which change with updates). They're your own values, so they aren't marked as suggestions; the form says which result they came from until you edit a field. If the browser reports a different GPU than on that result, the GPU's own fields (GPU, driver, overclock) start fresh. The graphics API is never carried: each run records its own.
  • Overclock details: with "Overclocked: Yes", the form asks for the core clock offset and memory clock offset (MHz, may be negative) and the power limit (%), as set in MSI Afterburner or the vendor's tool. They go into the submission YAML as an overclock: block right after overclocked: true (proposed upstream in docs/upstream-submission-changes.md), and are kept but ignored (not validated or exported) when overclocked isn't yes.
  • Detected by the browser: WebGL renderer/vendor, ANGLE backend, CPU thread count, battery state and browser version. They are stored in a separate, labelled detected: block, for the diagnosis only. They never fill in the GPU or any system: field. A software-rendered run (SwiftShader, llvmpipe, Microsoft Basic Render) can't be submitted.
  • Device context (deliberate exception): each result also saves a separate device field (src/app/share-env.ts: browser + major version, OS, phone/tablet/desktop, WebKit-engine flag, and the GPU name as reported by the browser, normalized and labelled "reported by browser"). This is a deliberate exception to the browser-hints rule, for display and sharing only: it never fills a system: field, is never copied into submission YAML, and older saved results are migrated with only safe inferences (user-filled OS, recorded browser/renderer; an old "Chrome N" stays unknown because other Chromium browsers recorded the same) or "unknown". Limit: iPadOS Safari sends a Mac UA, so an iPad is recognised only by a Mac UA plus more than one touch point; a touch-screen Mac would be misread as an iPad. See docs/privacy-audit.md.
  • The GPU should be an exact gpu_id from the upstream catalog. If your GPU isn't listed yet, choose My GPU isn't listed: the form then asks for Your GPU (its name) and the Requested gpu_id (the catalog ID you're asking for). An ID helper builds both: it shows the GPU lookup command (Windows by default, Linux on a switch), the user pastes what it printed, and src/benchmark/gpu-id.ts reads the computer type, each GPU's name, VRAM and board maker (PCI subsystem vendor), matches the chip to an upstream reference id (references in the catalog snapshot) and builds the id: laptop <reference>_reference (rtx_5090_laptop_24gb_reference, no laptop maker or model); desktop <reference>_fe / _reference / _le for the GPU vendor's own card, <reference>_<brand>_<product_line> when the line is typed, <reference>_<brand>_reference when only the brand is known, or <reference>_reference. reference stands in for an unknown variant (proposed upstream, see docs/upstream-submission-changes.md). The panel also gives the GPU's catalog file (data/gpus/<form_factor>/<vendor>/<id>.yaml, with base: set to the reference) to download, copy or open in GitHub's new-file form. Name, requested id and desktop or laptop are saved with the result and carried to later runs, so you can keep running and saving until the GPU is added. The paste itself isn't saved, and the page never runs the command. The same panel offers Request your GPU (a prefilled GitHub issue, no coding needed) and Add it yourself (a pull request using upstream's templates). The YAML can still be downloaded, using the requested id as gpu_id, with a warning (submissionWarnings in validate.ts) and a YAML comment that the submission fails upstream checks unless the GPU is added to the catalog first or in the same pull request. Or, once it's listed, reopen the result from My Results, pick it and submit. Share links carry the requested id (not the name).
  • The request issue is prefilled with the GPU name you typed or, if blank, the name as reported by the browser, labelled as an editable starting point (gpuRequestUrl in src/benchmark/catalog.ts). The browser's name only seeds a catalog request: it never goes into a result or a system: field, and it reaches GitHub only when the user clicks the link and submits the issue.
  • Validation (src/benchmark/validate.ts) fails closed and mirrors upstream templates/community_submission.yaml and scripts/checks.py. Required: catalog gpu_id (or, for an unlisted GPU, its name and a well-formed requested id), game_id, date, resolution, preset, avg_fps, p1_low, system.os (windows|linux), and proof.method. The resolution must be 720p, 1080p, 1440p or 4K, and match the rendered pixel size: older results from the removed Native option at any other size stay local only. A score must match its score run's locked settings and round(avg_fps × 100). overclocked is optional (yes/no); new runs start at no. memory_channels is optional, a whole number from 1 to 32. Linux-only fields on a Windows run fail. Blank optional fields are left out of the YAML. YAML download and copy stay disabled until validation passes.

Submitting

Submitting is optional, and it's up to you. Results stay in the browser unless you share, export or submit them. The site keeps nothing and sends nothing itself, so submitting isn't automatic: you submit to Open GPU Benchmarks yourself through GitHub, see exactly what's published, and decide what goes in it. Your GitHub username is public on the pull request. On desktop and laptop results the page shows a short, dismissible invitation to submit ("Not now" hides it; nothing is stored), and links to opengpubenchmarks.com to explore the charts. Nothing opens on its own and there are no pop-ups.

The results page builds result_YYYYMMDD_openbenchmarker_neon_flythrough.yaml for data/community/<gpu_id>/, and it can open GitHub's new-file form already filled in. Besides the upstream fields, each result item carries run details. These are the score and score_test (score runs), rendered_with (the bloom/MSAA/shadows/reflections actually used) and diagnostics (the limit verdict and the signals behind it). A top-level detected: block holds the browser-read values, and there is an optional top-level overclocked. Upstream accepts these today without validating them. docs/upstream-submission-changes.md spells out the checks.py/template changes that make them first-class.

The first PR that uses this benchmark must also add it to upstream data/games.yaml:

  - id: openbenchmarker_neon_flythrough
    name: OpenBenchmarker Neon Flythrough
    type: synthetic
    year: 2026
    url: https://opengpubenchmarker.com/

Submitted results join a public dataset under CC BY 4.0 (dataset license), a common license for open data. We believe benchmark results should be open to everyone, so anyone can use, share and build on the data, for any purpose, as long as they credit the source. Submitted results stay public, so leave any optional field blank if you'd rather not share it.

GPU catalog snapshot

src/benchmark/gpu-catalog.json is a dated snapshot of upstream data/gpus/<form_factor>/<vendor>/*.yaml (excluding reference/). The catalog is new and growing: on 2026-10-23 it holds one product, rtx_5080_16gb_fe (upstream also has 309 reference/ base models, which results can't use). Every card added makes the dataset more useful. To get one added, request it with an issue or open a pull request (see Honest-data rules); docs/upstream-todos.md lists how requests are handled upstream. After upstream merges new cards, refresh the snapshot and release:

npm run refresh-catalog   # optionally GITHUB_TOKEN=... to avoid rate limits

The script refuses to overwrite the snapshot with an empty list.

How to add a field

System information fields (what the user provides, under system: in the YAML) are defined once, in src/benchmark/fields.ts. The results form, validate.ts, the YAML and CSV exporters, JSON export and the storage migration all read that registry. To add one:

  1. Check upstream first. Find the field in open-gpu-benchmarks templates/community_submission.yaml and scripts/checks.py, and copy its YAML name, type, allowed values and position. If upstream doesn't have it yet, keep it out of the submission YAML until it does (and note it in docs/upstream-todos.md).
  2. Add the key to SystemInfo in src/benchmark/types.ts.
  3. Add a registry entry in SYSTEM_FIELDS, at its upstream YAML position: yaml name, kind (text is quoted, digits is a bare number, signed a bare number that may be negative, choice a bare word), section (top, system, or overclock for the block that only applies with overclocked yes), label, placeholder, optional help, linux: true for Linux-only fields, and a validate rule that mirrors checks.py. If browsers can't detect it and a best guess helps, add a suggest rule; the value is then marked in the result's suggested list and the YAML comments it as assumed. Set carry: true if it rarely changes between runs on the same machine, and gpuSpecific: true if it describes the GPU itself.
  4. Tests: extend tests/fields.test.ts (validation, YAML output and order, CSV column, and that older saved results load with it blank or suggested).
  5. Share links don't carry system fields, except the catalog GPU and overclock details (the GPU entry, tag 2). If a new field ever should be in links, add it as an extension entry with a new tag (see docs/share-links.md), not as a fixed field; the format version changes only for breaking changes.

Old saved results need no migration code: missing fields load as blank (or suggested), unknown fields from newer versions are kept, and a field the user cleared stays cleared.

Privacy and sharing

Everything runs in the browser. The site stores nothing on a server: no database, no accounts; the site sets no cookies and adds no analytics. It's hosted on Cloudflare, which may add its own beacons or other technology to keep the site running; nothing in the site's code collects user data. See the About page. Results live in this browser's localStorage. They leave it only when you share a link, export a file, or submit through GitHub. Share links carry the result in the URL fragment, which browsers never send to the server, and sharing is on the honor system (nothing is signed or verified). Links are encoded, not encrypted: anyone with a link can read it, like a screenshot. Of the system details you typed in, they carry only the catalog GPU you picked and the overclock details (so others can set up the same test); never CPU, memory (including memory_channels), OS version or driver. They never carry the diagnostics or the result id. The full list of storage keys and checks is in docs/privacy-audit.md, and the link format in docs/share-links.md.

The About & FAQ page (#/about, src/app/about-view.ts) explains all of this in plain words: principles, every storage key, what the host sees, how sharing works (with a field table decoded from a real link), submitting, phones, and the FAQ. Sections and FAQ entries can be linked directly, e.g. #/about/sharing or #/about/faq-memory-channels. tests/about.test.ts keeps the page honest: its storage list must match the keys in src/, its example link must decode to the values shown, and every #/about/<id> link elsewhere must have a target.

Phones and tablets

Phones and tablets can run the benchmark, and share or compare results. They can't submit to Open GPU Benchmarks yet, because the dataset lists desktop and laptop GPUs (an upstream schema change is planned; see docs/upstream-todos.md). Phones can run out of memory at 1440p and 4K, so the site asks for a one-tap confirmation before those runs and offers a lower resolution after a crash (docs/high-res-memory.md). The layout is designed for 360–430 px first.

Browsers can't detect memory channels, so phone and tablet results store memory_channels: 4 as a marked suggestion (flagship phones run four 16-bit LPDDR channels; many others use two). It isn't shown on the phone results page yet, but it's in JSON/CSV exports, and "4 (assumed)" shows in My Results. That way the saved result is already complete when phone submission opens. Phone and desktop channel counts aren't comparable: four phone channels add up to the width of one 64-bit desktop channel.

Search engines and sharing

Everything Google Search and link previews need ships from index.html and public/:

  • <title>, meta description, robots, and a canonical link to https://opengpubenchmarker.com/. The app uses hash routes (#/history, #/result/…, #/about), which search engines treat as the same page, so there is one indexable URL. The About & FAQ page is deliberately part of it rather than in the sitemap; if it ever needs its own search listing, it would move to a static /about.html.
  • Open Graph and Twitter card tags, with public/og-image.png (1200×630, a real frame of the Neon Flythrough on Ultra).
  • WebApplication JSON-LD structured data (free, MIT, requires WebGL2).
  • robots.txt (points at the sitemap) and sitemap.xml.
  • Icons: favicon.ico (48×48), favicon.svg, apple-touch-icon.png, plus site.webmanifest with 192 and 512 px icons.
  • _headers: basic security headers, and long-lived caching for Vite's hashed /assets/*.
  • A <noscript> notice and a footer with site, project and license links.

After the first deploy:

  1. Add the domain in Google Search Console. The Domain property type verifies through a DNS TXT record; add it under Cloudflare → DNS.
  2. Submit https://opengpubenchmarker.com/sitemap.xml under Sitemaps.
  3. Use URL Inspection on the home page and request indexing.
  4. Optional: check the structured data with the Rich Results Test.

When the home page changes meaningfully, update <lastmod> in public/sitemap.xml.

Future plans

See TODO.md: a Safari check, a separate score leaderboard site (submitted to separately from Open GPU Benchmarks), and diagnostics that need more than a browser.

License

  • Code: © 2026 the Open GPU Benchmarker maintainers. Code released under the MIT License. The Open GPU Benchmarker name and logo aren't covered by the MIT License.
  • Submitted results: results you submit to Open GPU Benchmarks are published under CC BY 4.0 and can't be taken back.
  • Third-party: three.js is MIT-licensed and is bundled into the build. Build tools (Vite, TypeScript, Vitest, Wrangler) aren't shipped.

Layout

src/benchmark/metrics.ts      computeStats / run stats (GPU busy, CPU time, stalls, hitches) / scoreFor (pure)
src/benchmark/diagnose.ts     "what limited this run": verdict + findings (pure)
src/benchmark/presets.ts      RESOLUTIONS, PRESETS, durations, SCORE_TESTS
src/benchmark/catalog.ts      catalog snapshot, NOT_LISTED, BENCHMARKS (exactly one), version
src/benchmark/types.ts        SystemInfo, BenchResult
src/benchmark/fields.ts       system field registry: form, validation, YAML/CSV, migration, suggestions
src/benchmark/validate.ts     validateSubmission (fail-closed)
src/benchmark/exporter.ts     toYaml / toCsv / toJson / file names
src/main.ts                   entry: routing, config form (dev-only ?capture hook)
src/app/run.ts                run controller (warm-up, timed batches, GPU timers, validity)
src/app/results-view.ts       results page, system form, export & submit
src/app/history-view.ts       My Results (#/history): card list, filters, sort
src/app/result-display.ts     how a result is shown, by the device that ran it (pure, tested)
src/app/home.ts, shell.ts, router.ts, store.ts, hud.ts, env.ts, dom.ts
src/app/share-env.ts          device detection for display/sharing only (pure, tested)
src/app/run-guard.ts          crash detection (run-in-progress record), "didn't finish" hints, phone high-res confirmation
src/app/share.ts              share link, share text, share card (canvas + QR) and the share flow
src/app/shared-view.ts        shared-link view (#/r/<payload>), read-only
src/app/about-view.ts         About & FAQ (#/about, #/about/<section>)
src/benchmark/share-codec.ts  share-link binary format (versioned, CRC-8, plausibility checks)
src/app/capture.ts            dev-only: fixed-t renders and renderer.info for scripts/capture.mjs
src/engine/world.ts           grid, clearances, the 96 s flight keys, landmark bridges
src/engine/path.ts            FlightPath (pose as a function of t) + 3D clearance index
src/engine/towergen.ts        lots, tower archetypes, detail kits, bridges, filler (pure)
src/engine/layout.ts          solids index, drones, far swarm, traffic lanes (pure)
src/engine/signlayout.ts      sign and lantern-string placement (pure)
src/engine/signatlas.ts       generated sign atlas (words, stroke glyphs, billboards)
src/engine/windows.ts         procedural window facade material
src/engine/glow.ts            instanced glow sprites (nav lights, swarm, lamps)
src/engine/fog.ts             height fog (shader chunks + GLSL for additive effects)
src/engine/systems/*.ts       sky, atmosphere, towers, signs, rain, traffic, drones, fx
src/engine/city.ts            assembles the systems for a preset
src/engine/scenes.ts          makeFlythrough: camera, composer, sanitize, bloom fallback, reflector
scripts/refresh-catalog.mjs
scripts/capture.mjs           headless screenshots / load reports (Playwright, dev only)
tests/                        Vitest unit tests
e2e/                          Playwright browser tests (playwright.config.ts)

Headless captures (development)

scripts/capture.mjs starts the Vite dev server and drives headless Chromium (Playwright, a devDependency; run npx playwright install chromium once). It opens the dev-only ?capture page, which renders the scene at a fixed scene time. That page isn't in production builds.

node scripts/capture.mjs --out shots --presets Low,Ultra --times 5,20,40   # screenshots
node scripts/capture.mjs --info                       # draw calls / triangles per preset
node scripts/capture.mjs --timing [--breakdown]       # relative ms/frame (+ per system)
node scripts/capture.mjs --og public/og-image.png --t 8

Headless Chromium renders WebGL in software (SwiftShader), so images are exact but timings only compare relative load. Sign lettering uses the system's bold sans-serif (as v1 did); the katakana-style glyphs are drawn as strokes in code, so they need no CJK font.

Browser tests (e2e)

npm run test:e2e runs e2e/*.spec.ts with Playwright against a dev server on port 5179. Run npx playwright install chromium once per machine. Unit tests that need a DOM start with // @vitest-environment happy-dom; everything else runs in Node.

By default the tests use Playwright's bundled Chromium: SwiftShader WebGL, no WebGPU, no timer queries. That checks that pages work, not how fast they run. To test on a real GPU, choose the browser with environment variables, so nothing machine-specific is committed:

PW_CHANNEL=msedge PW_ARGS="--force-high-performance-gpu" npm run test:e2e   # installed Edge (or chrome)
PW_ARGS="--enable-unsafe-webgpu --enable-features=Vulkan" npm run test:e2e  # Linux: WebGPU over Vulkan
PW_CDP_URL=http://127.0.0.1:9333 npm run test:e2e   # a browser you started with --remote-debugging-port

PW_CDP_URL covers browsers Playwright can't launch well itself, such as Windows Edge driven from WSL (start it with --remote-debugging-port=9333 --force-high-performance-gpu; WSL needs mirrored networking to reach the dev server). The "reports graphics capabilities" test prints which renderer, WebGPU adapter and timer queries the browser under test has.

Notes on measurement

  • Headless or software (SwiftShader) numbers come from software rendering. Use them to compare relative load, not real GPU performance.
  • Two batches stay queued on the GPU (WebGL2), so a GPU-limited run keeps the GPU busy close to 100%. The results page reports GPU busy % from timer queries; Task Manager's GPU graph should agree.
  • The browser drives the GPU from one thread (the page's main thread, plus the browser's GPU process). A CPU-limited run is limited by that one thread's speed, so Task Manager shows low total CPU use on many-core CPUs.
  • On the dev server, ?inflight=N changes how many batches are queued (1 drains the GPU between batches, like 2.0.0). Production builds ignore it.
  • The results page shows "Loop busy %": the share of timed wall-clock time spent submitting frames or waiting on a full GPU queue, rather than yielding or updating the HUD.

Releases

Sponsor this project

Packages

Contributors

Languages