GLBForge budgets are contracts, not advice: glbforge analyze exits non-zero when an asset breaks its profile, the GitHub Action gates on it, and optimize targets it. A contract you cannot pin is not a contract, so every profile is versioned. A cap never changes in place: any change is a new version appended here with the reason, and --profile mobile-hero@1 (CLI, Action, MCP) keeps meaning exactly what it meant when you wrote it. --profile mobile-hero resolves to the latest version. Reports carry the label (mobile-hero@1), and list_profiles rationale=true returns this text from the MCP server.
Live copy: https://glbforge.dev/budgets/ · Source of truth: packages/core/src/profiles.ts (every published version is frozen there and tested).
The caps come from a small explicit model rather than from any one device:
tex/vram-estimate reports this number; KTX2/BasisU stays compressed on the GPU (4–8x less), which is why the KTX2 path exists.minSsim floor is the weakest of four fixed-camera renders (256px, 2x supersampled, smooth shading, textured) before vs after optimization, scored with SSIM (Wang et al. 2004, 11x11 Gaussian window). Shading runs in linear light and the rendered pixels are sRGB-encoded, the way a viewer does it. Floors were calibrated on the Meshy 7 fixture (1.99M triangles): the mobile-hero budget pass to 150k measures 0.964 with the 4K-texture variant and 0.979 with 2K; a 40k version 0.913 with visibly merged hair; 10k measures 0.751. (Pre-0.9.0 numbers on the same fixture were 0.958 / 0.896 / 0.709 — see the v2 changelog entry.)These are working assumptions, stated so they can be argued with. When usage data disagrees, the cap moves in a new version — never silently.
perf/* findings are budget violations (errors, −15 each; any error fails the asset). geo/*, topo/*, mat/*, tex/*, scene/* findings are warnings (−5) or info (0) describing defects typical of AI-generated assets, each with a concrete fix. fidelity/perceptual is an error when the measured SSIM is under the floor and an info finding carrying the number otherwise. Score = 100 − penalties, floored at 0.
| cap | value | why |
|---|---|---|
| maxTriangles | 150,000 | A mid-range phone GPU (2019+ Adreno 6xx / Mali-G7x / Apple A12 class) rasterizes 150k triangles in well under a millisecond; the binding constraint is payload. 150k welded, quantized, meshopt-compressed triangles land around 1-2MB, which is what leaves room for textures inside the file cap. Counted over the scene: a mesh placed by five nodes costs its triangles five times, because that is what is drawn. |
| maxDrawCalls | 4 | Counted as one call per primitive per node that places it — the floor a renderer without instancing support pays. Four keeps the per-frame state changes inside the budget of a mid-range mobile GPU driver; merging primitives that share a material is the cheapest way down. |
| maxTextureSize | 2048 px | A 2K RGBA8 texture with mipmaps is ~21MB of GPU memory; a color/normal/ORM set of three fits the VRAM cap. 4K quadruples that and rarely reads sharper on a phone-sized viewport. |
| maxTextureBytes | 4 MB | Compressed image payload inside the GLB. WebP at quality 82 (near-lossless for normal maps) keeps three 2K maps around 1–3MB. |
| maxTextureVramBytes | 128 MB | Decoded, mipmapped GPU memory. Mobile browsers share GPU memory with the OS and drop WebGL contexts that get greedy; 128MB leaves headroom for framebuffers, the DOM, and a second asset. KTX2/BasisU counts at its GPU-compressed size (~4–8x less), which is why the KTX2 path exists. |
| maxFileBytes | 6 MB | About 3–5 seconds on a typical 4G link (10–15Mbps effective) — the most a hero can hide behind a poster image before it reads as broken; under a second on Wi-Fi or 5G. |
| maxMaterials | 2 | Materials multiply shader variants and texture sets. A hero is one material, two when a glass or emissive part is unavoidable. |
| minSsim | 0.94 | Weakest of four fixed-camera views (256px, 2x supersampled, smooth shading, textured) before vs after optimization. Calibrated on the Meshy 7 fixture: the budget pass measures 0.964 with 4K source textures (0.979 with 2K), a 40k-triangle version 0.913 with visibly merged hair strands. The floor sits between them. |
| cap | value | why |
|---|---|---|
| maxTriangles | 500,000 | Integrated desktop GPUs handle 500k triangles per frame comfortably; beyond that, payload and parse time on first load dominate, not raster cost. Counted over the scene, so instanced placements each cost their triangles. |
| maxDrawCalls | 8 | Desktop browsers absorb more state changes per frame, and a desktop hero is often a small assembly (product + stand + shadow catcher). Counted as one call per primitive per node that places it. |
| maxTextureSize | 4096 px | A 4K color map can be justified on a large viewport where the hero fills half the screen; a 4K RGBA8 with mips is ~85MB decoded, so only the color map should be 4K. |
| maxTextureBytes | 12 MB | One 4K color map plus 2K normal/ORM maps in WebP. |
| maxTextureVramBytes | 256 MB | Desktop GPU memory is plentiful but shared with tabs and the compositor; 256MB keeps a two-asset page under half a gigabyte. |
| maxFileBytes | 20 MB | About 3 seconds on a 50Mbps connection; desktop visitors tolerate a progressive reveal behind a placeholder up to that. |
| maxMaterials | 4 | Four materials cover a typical product hero (body, glass, metal trim, screen) without turning into a material zoo. |
| minSsim | 0.96 | Desktop heroes are viewed larger, so the floor is stricter than mobile: the budget pass to 500k measures 0.986–0.993 on our fixtures, clearing it either way. |
| cap | value | why |
|---|---|---|
| maxTriangles | 250,000 | Configurators keep several variants resident and the camera gets close; 250k per asset balances close-up fidelity against having three or four assets loaded at once. Counted over the scene, so instanced placements each cost their triangles. |
| maxDrawCalls | 12 | Swappable parts are separate meshes by design (a draw call each), so the cap is higher than a hero — but still a dozen, not a hundred. Counted as one call per primitive per node that places it. |
| maxTextureSize | 2048 px | 2K keeps a multi-variant texture set inside the shared VRAM cap; configurators rarely benefit from 4K because the camera moves and materials swap. |
| maxTextureBytes | 8 MB | Room for a full PBR set (color, normal, ORM) at 2K in WebP plus one variant map. |
| maxTextureVramBytes | 128 MB | Same ceiling as mobile because a configurator page often IS on mobile, and several assets share it. |
| maxFileBytes | 12 MB | Configurator assets load on demand behind an explicit user action, so a slightly larger file is acceptable than a hero that must appear on first paint. |
| maxMaterials | 8 | Materials are the point of a configurator (colorways, finishes); eight covers realistic part counts while keeping shader compilation bounded. |
| minSsim | 0.95 | Close-up viewing argues for strict, coexistence argues for lenient; 0.95 is the midpoint, and the budget pass to 250k measures 0.977–0.987 on our fixtures. |
Profile object (version N+1) in profiles.ts; never edit a published one (the test suite freezes v1's numbers).getProfile('name') now returns N+1; anyone pinned to name@N is unaffected until they opt in.v3 — 2026-09-21. No cap moved; the measurement was corrected. Triangles and draw calls were counted over the mesh list — each mesh once, however many times the scene placed it. A renderer does the opposite: it draws a mesh once per node that references it. Any instanced asset was therefore measured at a fraction of its real cost and could pass a cap it exceeded several times over.
Measured on the Khronos ABeautifulGame chess set (15 meshes placed by 49 nodes), against mobile-hero:
| measure | @2 (mesh list) | @3 (scene) | real |
|---|---|---|---|
| triangles | 573,952 | 1,499,072 | 1,499,072 |
| draw calls | ~15 | ~49 | 49 |
| verdict vs 150,000 cap | 3.8x over | 10.0x over | 10.0x over |
Nothing changes for an asset that places each mesh once — every fixture in this repository and most generator output, where the two counts are identical. Instanced assets score lower than they did under @2 because the @2 score was wrong. EXT_mesh_gpu_instancing multiplies triangles but not draw calls, which is how the GPU bills it.
The simplifier was targeting the mesh-list count too, so optimize --profile mobile-hero could report reaching 149,170 triangles while the report card that ran a second later measured 166,978 and failed. Both now read the same number.
v2 — 2026-09-11. No cap moved; the measurement was corrected. The verification renderer sampled base-color textures as if their bytes were already linear, while baseColorFactor is stored and used as linear. Any surface that moved between those two slots was therefore scored across two different transfer curves — and the pipeline moves colour between them by itself: prune() folds a base-color texture that is one solid colour into the factor and drops the image. The delivered asset was correct and smaller; verify reported it as visibly lossy and failed the budget.
The renderer now decodes texels through sRGB, composes factor * texture as glTF defines it, shades in linear light, resolves supersamples in linear light, and sRGB-encodes the output. Isolated, the fold that used to score 0.9045 now scores 1.0000 (base color transfer curves in packages/core/test/perceptual.test.ts).
This moves every SSIM the tool reports, so the floors were re-derived rather than assumed. Weakest of four views, textured, on the Meshy 7 fixture:
| case | @1 | @2 |
|---|---|---|
| mobile-hero budget pass, 2K textures | 0.9761 | 0.9794 |
| mobile-hero budget pass, 4K textures | 0.9594 | 0.9637 |
| 40k triangles (visibly merged hair) | 0.8999 | 0.9132 |
| 10k triangles | 0.7094 | 0.7511 |
| desktop-hero budget pass (500k) | 0.9918–0.9873 | 0.9929–0.9864 |
| product-configurator budget pass (250k) | 0.9850–0.9765 | 0.9871–0.9774 |
Every floor still sits between its budget pass and its counter-example, so all three caps are unchanged and every profile's budget pass still clears with margin. The version moves anyway: the rationale text quotes numbers that a reader will compare against their own runs, and @1 has to keep meaning what CI recorded before 0.9.0. Note that pinning @1 does not restore the old renderer — there is one renderer and it is now correct; the pin preserves the caps and the record of how they were derived.
rules field: they pin the core-geometry@1 rule pack and report its topology findings (topo/open-edges, topo/non-manifold, topo/floating-fragments) as info — a renderer does not care whether a mesh is a closed solid. The same rules are warnings under the authoring@1 rule profile and will be errors under future print profiles. No cap moved and no exit code changed, so the version stays at 1.minSsim perceptual floor (calibrated as described above) and a published rationale per cap.