threeops
v0.9.1
Published
3D performance for three.js / R3F projects, as steps in your own pipeline: optimize assets on export, guard builds, verify a deployed preview against live
Maintainers
Readme
threeops
3D performance for three.js and React Three Fiber projects. Your scenes get light enough for phones, and stay that way. ThreeOps isn't a CI/CD system. It adds a few steps to the pipeline you already have:
| Step in your pipeline | Command | What it does |
| --- | --- | --- |
| Asset export / build | threeops-optimize <models dir> | KTX2 textures that stay compressed on the GPU, meshopt geometry, texture caps. Skips files it already did |
| Before deploy | threeops-optimize --check <models dir> | Stops the build if an asset didn't finish optimizing |
| Before deploy, or in CI | threeops verify --local | Builds and serves this project locally, measures it against the live site (or --against-ref main), exits 1 if anything got worse |
| After a preview deploy | threeops verify <preview-url> | Measures the preview against the live site on a throttled phone, writes a before/after report, exits 1 if anything got worse |
| Once, to set up | threeops fix | Wires the above into the project: KTX2 loading, optimize on export, the build guard |
threeops init [dir] detect the stack, write threeops.config.json
threeops fix --project <dir> one-time setup, in a separate git worktree
threeops optimize <dir> same as threeops-optimize, from the CLI
threeops verify [url] [--against <url>] measure url against the live site (or --against), report, exit 0/1
threeops verify --local [--against-ref <ref>] the same for this build, served locally: no deploy (CI, before a push)
threeops files <paths...> analyze local .glb/.gltf files, report with optimized copies (no browser)
threeops runs what ran on this project, newest last
threeops doctor [--project <dir>] is this machine (and project) ready to export and ship optimized models?
threeops login / logout free account on the ThreeOps service, for sharing reports (coming soon)
threeops share [report] share a report as a link anyone can open (coming soon)
threeops agents [--project <dir>] install the Claude Code skill, print how to connect the MCP server
threeops mcp MCP server on stdio, for coding agents
threeops firebase deploy | promote optional, for Firebase projects without a deploy pipelineEvery run is recorded in the project, in .threeops/runs/ (one threeops.run/v1 JSON file per run; the folder ignores itself in git). Reports go to .threeops/reports/. Nothing is sent anywhere unless you share a report.
Projects that threeops fix set up don't install this CLI: they get the small @threeops/optimize package (vendored as vendor/threeops-optimize-<version>.tgz), so their npm scripts can call threeops-optimize. The CLI is for setting up and for verify, which needs a browser. From the monorepo root, run it as npm run threeops -- <command>.
Coding agents
ThreeOps is built to be used by coding agents as much as by people:
MCP server:
threeops mcpserves the same actions over stdio. The tools arescan_project,analyze_models,check_models,optimize_models,setup_project,verify_deploy(a URL, orlocal: truewith an optionalagainstRef),share_report,list_runsanddoctor. Each answers with a one-line summary followed by JSON. Read-only tools are marked as such, and errors say what to do next ("install KTX-Software 4.3+", "stop the dev server", "pass the preview URL").verify_deploysends progress notifications while it measures.Claude Code skill:
.claude/skills/threeops/SKILL.mdsays when to reach for ThreeOps (adding models, slow or crashing scenes on phones, before and after a preview deploy), the workflow, and the rules (KTX2 not WebP, load through the helper, never remove the build guard).threeops fixinstalls it when the project uses Claude Code (a.claude/folder in the project or repo root).threeops agentsinstalls it anywhere and prints the command that connects Claude Code to the MCP server:threeops agents --project <dir> claude mcp add --scope user threeops -- threeops mcpTHREEOPS.md, written byfix, tells any agent (and person) what ThreeOps changed in the project and what not to undo.AGENTS.md,CLAUDE.mdand Copilot instructions get a pointer to it.
For scripts: --json prints results on stdout (logs go to stderr). Exit codes are stable: 0 ok, 1 the check failed (verify got worse, unoptimized assets, a high-severity finding), 2 a usage or setup problem (no URL, KTX-Software missing), 3 fix stopped before finishing (the next run completes it).
Setting up a project
threeops init <dir>scans the project:- three, R3F and drei versions
- how glTF is loaded, and whether KTX2 and meshopt are set up
- eager preloads
- where models live, and whether a script generates them (git-ignored output)
- Firebase hosting
Review the
threeops.config.jsonit writes:url(the live site, verify's default reference),tier,textureCap, andstepsfor scenes behind clicks.threeops fix --project <dir>works in a separate git worktree next to the repo (<repo>.threeops, branchthreeops/fix-<date>), so your checkout, its branch, uncommitted work and any running dev server are never touched. The worktree gets its ownnode_modulesand a copy of the git-ignored exports inpublic/. It applies:- KTX2 loading (R3F + drei): adds
src/threeops/gltf.ts, a drop-inuseGLTFthat sets up a sharedKTX2Loader. TheuseGLTFimports are pointed at it; nothing else in those files changes. The Basis transcoder is self-hosted inpublic/basis/. - Build guard:
threeops-optimize --check <models dir>in theprebuildscript, so a build stops if an export didn't finish optimizing. - Asset pipeline:
threeops-optimize <models dir>is appended to the script that regenerates the models (contentStep), so every export is optimized.@threeops/optimizeis packed into the project'svendor/and added as a dev dependency, so a fresh clone or CI cannpm ci. Projects fixed before 0.3 (which vendored the whole CLI asthreeops-audit) are migrated on the nextfix. - Cache rule: a Firebase cache rule for the transcoder, inserted as text so
firebase.jsonkeeps its formatting. - Current models: optimized right away.
THREEOPS.mdin the project: what changed and why, what not to undo, day-to-day commands, and a section for coding agents. If the project hasAGENTS.md,CLAUDE.mdor.github/copilot-instructions.md, a one-line pointer is added there. Both live betweenthreeops:start/endmarkers, so anything people write outside them survives reruns.
The changes are committed on the worktree's branch (
--no-committo skip). Merge withgit merge threeops/fix-<date>from your checkout, thengit worktree remove <repo>.threeops. Reruns reuse the worktree and are idempotent.--in-placeedits the current checkout on its current branch instead (CI, solo use); then nothing is committed unless you pass--commit. If a dev server is running from that checkout,fixstops beforenpm install(on Windows it would fail half-way on locked files), names the server's PID, and the next run finishes the install. Fixes that need a decision in the code (lazy loading, merging draw calls) are listed as guided.- KTX2 loading (R3F + drei): adds
Check locally (
npm run build,npm run preview), deploy a preview the way you always do, then verify it.
Verify
threeops verify https://my-game--pr-42.web.app # against the live site in threeops.config.json
threeops verify https://staging.example.com --against https://example.com
threeops verify https://preview.example.com --json # for pipelines and agentsBoth URLs are measured fresh, one right after the other so they don't compete for CPU, as the same mid-range phone (4× CPU slowdown, fast 4G) with the same steps. Nothing is compared with an old saved audit, so the network and machine are the same for both.
It passes when the new version still loads the models and no measured number got meaningfully worse: texture memory uploaded to the GPU (+5%), model download (+10%), draw calls or triangles (+20%), plus no load error, no lost WebGL context, and a first 3D frame. Timings such as first frame are reported but don't fail the check: they vary between runs with CDN warm-up.
It writes, in .threeops/reports/verify-<host>-<time>/ (or --out):
report.html, led by the measured result, then the usual findings for the new versionside-by-side.html: both versions running next to each other in phone or desktop frames. Serve the folder to open it; iframes don't load fromfile://verify.json, and the reference's own capture and report inagainst/
A capture counts as settled when both draw calls and uploaded GPU memory have held steady for 5 s (90 s at most): large textures can take many seconds to decode on a throttled phone CPU while the frame doesn't change.
--steps <file> (or steps in the config) takes a module whose default export (page, { sleep, log }) drives the Playwright page after load, for scenes that only load after clicks (games, configurators). Set THREEOPS_CHROMIUM to use an installed Chrome instead of Playwright's Chromium.
Without deploying: --local
threeops verify --local # build, serve the output on 127.0.0.1, measure it against live
threeops verify --local --no-build # measure the build that's already there
threeops verify --local --against-ref main # this build against main, both built and served locally--local runs the project's build (build in threeops.config.json, else npm run build), serves the output folder (output, the Firebase public dir, or dist/build/out) on a local static server that falls back to index.html for client routes, and measures it on the same throttled phone as the live site. GPU memory, draw calls, triangles and "do all models load" are the same as on your host. Compression and caching aren't (they're the local server's), so delivery findings are left out for the local side and downloads are compared uncompressed. Static builds only (Vite, CRA, static exports); a server-rendered app needs a deployed URL.
--against-ref <ref> measures one change on its own. The project folder at the ref (a branch, tag or commit) is exported to a temporary folder (only that folder: not the rest of a big game repo, and with no worktree or change to your repository), given its own npm ci, built and served next to this build. Both sides then run on the same machine and the same local server, so only the code differs. The temporary folder is deleted afterwards. If the project's generated exports are git-ignored (models exported from Blender, say), the ref has none. It then gets this checkout's copy, and the report says the comparison shows code changes only. --no-copy-ignored turns that off.
In CI
The same command gates a pull request with no deploy: the runner builds the branch, serves it, and compares it with what players have now.
# .github/workflows/threeops.yml
name: 3D performance
on: pull_request
jobs:
threeops:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx threeops verify --local # exits 1 if the build got heavier than live
# or measure the pull request on its own (needs the base branch: fetch-depth: 0 on checkout)
# - run: npx threeops verify --local --against-ref origin/${{ github.base_ref }}
# env: { THREEOPS_TOKEN: ${{ secrets.THREEOPS_TOKEN }} } # with --share: a link for the PR
- uses: actions/upload-artifact@v4
if: always()
with: { name: threeops-report, path: .threeops/reports }npx threeopsneeds the CLI on npm. Until it's published, install it from a packed tarball or a git checkout in a step before.- Stable numbers on shared runners: CI machines are noisy, so the check only fails on numbers that don't depend on machine speed: GPU texture memory, model download, draw calls, triangles, models loaded. Timings are reported, never gated.
- Models must exist in CI. If they're generated outside git (exported from Blender, git-ignored), the runner has none and the build guard stops the build. Commit the optimized exports, fetch them from storage in a step before the build, or run the export in CI.
- Which reference: live (the default) answers "is this heavier than what players have now".
--against-ref origin/<base>answers "what did this pull request change", and needs the base branch fetched (actions/checkoutwithfetch-depth: 0).
Sharing a report
Coming soon: the hosted service isn't live yet. Until it is, login, share and verify --share say so (and verify --share still verifies); everything else works without it. Here's how it will work:
threeops login # once: a code in the terminal, confirmed in the browser (GitHub or Google)
threeops verify https://preview.example.com --share
threeops share # the newest report in this project (or pass a report folder)
threeops share --list
threeops share --delete <id>A shared report is a link anyone can open, no account needed. It is deleted after 7 days without a view; each view keeps it for another 7 days. What is sent: the report's data (numbers, findings, the URLs measured) and its images as WebP (screenshot, renders). Local file paths are removed first, and the service renders the page itself from that data: it never hosts uploaded HTML. In CI, set THREEOPS_TOKEN instead of logging in; THREEOPS_API points the CLI at another service (e.g. http://localhost:5000, the service on the Firebase emulators). Agents use the MCP tool share_report, which says it uploads, so clients can ask first.
Firebase helper (optional)
For Firebase Hosting projects that deploy from a laptop and have no pipeline:
threeops firebase deploy --project <dir> # build + preview channel threeops-<branch>, never live
threeops verify <preview-url> --project <dir>
threeops firebase promote --project <dir> --yes # copy the verified channel to livepromote refuses unless the last verify of that channel passed. Without --yes it only says what it would do.
Optimize
threeops optimize / threeops-optimize is idempotent:
- Skips finished files: a
.threeops-cache.jsonmanifest records each output's hash and settings, so reruns skip files it already produced. - Keeps originals when nothing improves: if the optimized file isn't lighter on the GPU or the wire, the original stays.
- KTX2 or a loud stop: without KTX-Software 4.3+ it exits 2 instead of quietly making WebP textures, which use 4-8x more GPU memory.
--allow-webpmakes WebP on purpose.
Setup
Requires Node 20+.
npm install
npx playwright install chromium # needed for verify
npm run samples # optional: two Khronos test models in ./samples
node bin/threeops.mjs doctor # checks KTX-Software, Chromium, Firebase CLIInstall KTX-Software 4.3+ to optimize models (THREEOPS_KTX points at it if it isn't on PATH).
Run npm test for the unit tests (codemod, worktrees, busy detection, commit messages, Firebase output parsing).
Local files
node bin/threeops.mjs files samples/
node bin/threeops.mjs files public/models/hero.glb --no-optimizeAnalyzes each model, writes a report with optimized copies and before/after renders, and exits 1 on a high-severity finding.
Output
Each report (verify, files) is a folder under .threeops/reports/ in the project (or --out <dir>). A verify report is about 1 MB: the copies of every downloaded file (assets/) are deleted once the report is written (--keep-assets keeps them). Only the newest 20 reports are kept ("reports": { "keep": 20 } in threeops.config.json; false keeps all).
| File | What it is |
| --- | --- |
| report.html | The visual one-page report, easy to share with the people who own the game: headline, budget meters, breakdown by asset group, the top three fixes with charts, GPU memory after each fix, every model. Light and dark themes, works on phones |
| report.md | The same findings as plain Markdown |
| findings.json | Every number and finding, machine-readable (threeops.audit/v1) |
| compare.html | Before/after viewer with one shared camera. Serve it: npx http-server <report folder> -o compare.html |
| optimized/<group>/ | Optimized models: the report's file fixes applied (KTX2 or WebP textures, outliers resized, meshopt geometry) |
| renders/ | Before/after images of the model with the biggest GPU saving in each of the top 3 groups, shown in the report as proof |
| capture.json, screenshot.png | verify only: what the browser downloaded (URLs, sizes, headers) and the runtime stats, a screenshot. assets/ (the files themselves) only with --keep-assets |
What it measures
Static, per model (from the file): size, triangles, estimated draw calls, materials, textures with sizes and roles, and estimated GPU memory (textures decoded as RGBA8 with mipmaps, plus vertex/index buffers).
Runtime, per site (from the browser): time to the first WebGL draw, draw calls and triangles per frame, three.js revision, WebGPU use, WebGL context loss, and GPU memory actually uploaded (textures, render targets and buffers, from hooks on the WebGL upload calls). Comparing uploaded textures with what was downloaded shows assets that were fetched but never drawn. Headless runs use a software GPU (SwiftShader), so frame times there aren't representative. Use --headed for real ones. Draw-call, triangle and upload counts are accurate either way.
Asset groups: each model is grouped by the folder it loads from (/models/characters/x.glb → characters), so the report can say which kind of asset carries the weight.
Findings (stable IDs, mobile budgets in src/budgets.mjs):
| ID | Fires when |
| --- | --- |
| FILE_OVER_BUDGET | A model alone exceeds the scene's download budget (8 MB mobile) |
| TEX_OVERSIZED | Textures exceed 1024 px (mobile) / 2048 px (desktop) |
| TEX_UNCOMPRESSED | PNG/JPEG/WebP textures where KTX2 would stay compressed on the GPU |
| GPU_MEM_OVER_BUDGET | Estimated GPU memory exceeds 128 MB (mobile) |
| TRIS_OVER_BUDGET | More than 300k triangles (mobile) |
| DRAWCALLS_HIGH | More than 100 estimated draw calls (mobile) |
| MESH_UNCOMPRESSED | No meshopt/Draco on a mesh over 50k triangles |
| MATERIALS_DUPLICATE | Identical materials defined more than once |
| NPOT_TEXTURE | Non-power-of-two textures |
Scene-wide findings, across files:
| ID | Fires when |
| --- | --- |
| ASSETS_NOT_DRAWN | Downloaded textures would need 20%+ (and 32 MB+) more GPU memory than the page actually uploaded: assets preloaded but never shown |
| TEX_OUTLIER | In a group of 5+ files, some use 2048 px+ textures, more than twice the group's typical size |
| ANIM_DUPLICATE_SETS | 3+ animation-only files carry the same set of clips |
| IMAGES_OVERSIZED | Standalone image files (posters, decals) exceed the texture cap |
| DOWNLOAD_OVER_BUDGET | All 3D models together exceed the download budget |
| DRAWCALLS_RUNTIME | Measured draw calls per frame exceed the budget |
| CACHE_SHORT | Models/images over 50 KB cached for less than a day, or without Cache-Control |
| CACHE_UNHASHED_IMMUTABLE | immutable caching on file names without a content hash |
| NOT_COMPRESSED | glTF, JSON, JS, CSS, wasm or SVG over 20 KB served without gzip/brotli |
| IMAGE_OVERSIZED_FOR_DISPLAY | An image has 2.25× more pixels than its on-screen size × DPR |
Project findings (verify scans the project): KTX2_LOADER_MISSING, EAGER_PRELOADS, ASSETS_GENERATED. The report's "Fix plan" marks each finding as automatic (threeops fix / optimize apply it) or guided.
The report's "GPU memory after each fix" chart applies the fixes in order (load only what is drawn → resize textures → KTX2), scaling each step by what the previous ones left, so savings are not counted twice.
Optimization
The "after" file is built with glTF-Transform: dedup → instance → prune → weld → resample → resize textures to the tier cap (texture outliers get their group's lower cap) → textures → meshopt geometry. With KTX-Software installed, textures become KTX2: ETC1S for colour, UASTC for normal maps at half the cap (UASTC is large on disk). They stay compressed in GPU memory. Without it, textures become WebP, which shrinks downloads but not GPU memory. The report's "Already done" section shows the real totals of these files, re-analyzed, plus before/after renders (same camera, rendered with three.js in headless Chromium; needs internet for three.js from jsDelivr).
Example, Khronos "A Beautiful Game" sample on the mobile tier: 41.0 MB → 11.9 MB download; ~727 MB → ~185 MB GPU memory with WebP, ~53 MB estimated with KTX2.
Limits
- GPU memory and draw calls in files are estimates; runtime numbers come only from
verify, which loads the site. - Models built in code aren't captured. Models loaded after a click need a
--stepsscript. ASSETS_NOT_DRAWNcompares totals; it can't yet name the individual files that were never drawn, only the largest groups.- No mesh simplification or LODs yet; triangle findings are advice only.
- Always check optimized models in
compare.htmlbefore quoting savings. The renders cover only three models.
Project layout
bin/threeops.mjs CLI: init, fix, optimize, verify, files, runs, firebase deploy/promote, agents, mcp, doctor
src/actions.mjs What the commands do, shared by the CLI and the MCP server
src/mcp.mjs MCP server (stdio)
src/service.mjs The ThreeOps service from the CLI: login, share
skills/threeops/ The Claude Code skill
src/project/scan.mjs Project scan: stack, loaders, preloads, generated assets, hosting
src/project/doctor.mjs Machine and project readiness checks
src/fix/ threeops fix: R3F + drei KTX2 loader, pipeline hook, vendoring, worktree, THREEOPS.md, skill, busy detection
src/deploy/ Firebase helper: preview channels, promote
templates/gltf.ts The KTX2-aware useGLTF that threeops fix installs
test/ Unit tests (npm test)Measuring (capture, findings, before/after comparison, reports) lives in @threeops/measure; optimizing and run records in @threeops/optimize.
