Performance
Measured in desktop Chromium on the town scenes coding agents wrote and on the kit's explorable town. The numbers describe these scenes, not yours: measure your own with town.optimization.stats() and renderer.info.render.
Desktop only
Desktop Chromium only; frame times are reported, not judged (SwiftShader is software rendering on a shared machine: relative cost only). No mobile claim.
Scene optimization (SDK 0.5.1)
library.assembleScene(scene, { optimize: true }) (Three.js) or <OptimizedScene> (React Three Fiber) draws every mesh that at least two instances share with GPU instancing. Only the draw calls change; triangles are counted per instance either way.
- Agent-written towns (10)
- 169–264 → 63–93 — draw calls, −55.6 % to −69.6 %
- Explorable town (161 instances)
- 621 → 148 — draw calls, −76.2 %
- Largest pixel difference
- 0.070 % — of the pixels; pass condition <= 0.1 % pixels > 16/255
The draw counts are identical in every run (Host GPU and Software rendering (SwiftShader)).
Host GPU Passed
ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Pro, Unspecified Version), Apple M2 Pro, 1280x800, 120 frames per measurement, measured 2026-10-26. API: library.assembleScene(scene, { optimize: true }) (runtime-three optimizeScene, preset 'large-scene').
| Scene | Instances | Draw calls | Change | Pixels | Frame ms |
|---|---|---|---|---|---|
| Claude Code · with MCP run of 2026-10-24 08:00 | 31 | 212 → 87 | −59.0 % | 0.021 % | 1.6 / 1.5 |
| Claude Code · with MCP run of 2026-10-24 12:53 | 34 | 217 → 93 | −57.1 % | 0.025 % | 1.7 / 1.5 |
| Claude Code · without MCP run of 2026-10-24 08:05 | 36 | 222 → 91 | −59.0 % | 0.031 % | 1.7 / 1.6 |
| Claude Code · without MCP run of 2026-10-24 12:57 | 52 | 264 → 91 | −65.5 % | 0.032 % | 1.8 / 1.7 |
| OpenAI Codex CLI · with MCP run of 2026-10-24 08:09 | 25 | 198 → 75 | −62.1 % | 0.031 % | 1.7 / 1.5 |
| OpenAI Codex CLI · with MCP run of 2026-10-24 08:16 | 21 | 169 → 63 | −62.7 % | 0.027 % | 1.5 / 1.5 |
| OpenAI Codex CLI · with MCP run of 2026-10-24 13:03 | 21 | 175 → 67 | −61.7 % | 0.028 % | 1.5 / 1.4 |
| OpenAI Codex CLI · with MCP run of 2026-10-24 13:07 | 28 | 207 → 63 | −69.6 % | 0.036 % | 1.6 / 1.5 |
| OpenAI Codex CLI · without MCP run of 2026-10-24 08:18 | 31 | 221 → 88 | −60.2 % | 0.030 % | 1.8 / 1.1 |
| OpenAI Codex CLI · without MCP run of 2026-10-24 13:09 | 22 | 180 → 80 | −55.6 % | 0.024 % | 1.6 / 1.5 |
| Explorable town the kit scene | 161 | 621 → 148 | −76.2 % | 0.070 % | 2.2 / 2.1 |
Software rendering (SwiftShader) Passed
ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver), Apple M2 Pro, 1280x800, 40 frames per measurement, measured 2026-10-26. API: library.assembleScene(scene, { optimize: true }) (runtime-three optimizeScene, preset 'large-scene').
| Scene | Instances | Draw calls | Change | Pixels | Frame ms |
|---|---|---|---|---|---|
| Claude Code · with MCP run of 2026-10-24 08:00 | 31 | 212 → 87 | −59.0 % | 0.003 % | 50.5 / 51 |
| Claude Code · with MCP run of 2026-10-24 12:53 | 34 | 217 → 93 | −57.1 % | 0.009 % | 46 / 45.6 |
| Claude Code · without MCP run of 2026-10-24 08:05 | 36 | 222 → 91 | −59.0 % | 0.016 % | 50.5 / 48.6 |
| Claude Code · without MCP run of 2026-10-24 12:57 | 52 | 264 → 91 | −65.5 % | 0.006 % | 54.8 / 55.8 |
| OpenAI Codex CLI · with MCP run of 2026-10-24 08:09 | 25 | 198 → 75 | −62.1 % | 0.002 % | 48.1 / 46.9 |
| OpenAI Codex CLI · with MCP run of 2026-10-24 08:16 | 21 | 169 → 63 | −62.7 % | 0.022 % | 44.7 / 44.3 |
| OpenAI Codex CLI · with MCP run of 2026-10-24 13:03 | 21 | 175 → 67 | −61.7 % | 0.009 % | 42.9 / 42.3 |
| OpenAI Codex CLI · with MCP run of 2026-10-24 13:07 | 28 | 207 → 63 | −69.6 % | 0.022 % | 45.8 / 48.2 |
| OpenAI Codex CLI · without MCP run of 2026-10-24 08:18 | 31 | 221 → 88 | −60.2 % | 0.008 % | 44.5 / 47.6 |
| OpenAI Codex CLI · without MCP run of 2026-10-24 13:09 | 22 | 180 → 80 | −55.6 % | 0.011 % | 41.5 / 77.9 |
| Explorable town the kit scene | 161 | 621 → 148 | −76.2 % | 0.028 % | 187 / 117.2 |
Draw calls: per frame, plain → optimized. Pixels: share of the frame that differs by more than 16/255 between the plain and the optimized render. Frame ms: median render and GPU sync, plain / optimized — reported, not judged.
What was judged
- draw calls: agent-written towns: optimized <= 50 % of plain; every other scene: fewer draw calls (the reduction is recorded)
- pixels: <= 0.1 % pixels > 16/255
- picking: identical first visible hit
- semantics: customize after optimize, clip; variants scenes: parts picked, both variants batched
- frame time: reported, not judged.
Fewer draw calls cut CPU and driver work per frame. Whether that makes frames faster on slower or mobile GPUs was not measured, so it is not claimed.
When not to batch an instance
The optimization keeps the SDK’s behaviour by itself: playClip and town.customize hand an instance back and batch it again, and an instance you move, hide or give another material is drawn by its own objects from that frame on. Leave an instance out of the batches when your code changes it often or decorates it per object:
| Your instance … | If it is batched | Do this |
|---|---|---|
is animated by your own code (a mixer, a tween — not playClip) | handed back on the first changed frame (same image), batched again after it rests; every rest rebuilds all batches | exclude it |
| gets its own material at run time (hover or selection highlight, a fade) | handed back on the frame you swap the material; a rebuild after each rest | exclude it, or unbatch(id) while highlighted |
carries a per-object render effect: onBeforeRender, customDepthMaterial, an outline or selection pass, a picking render that draws meshes one by one | not detected: while batched, its meshes are invisible to the renderer (a batch draws them), so these effects never see them | exclude it |
| is dragged or edited interactively (gizmos) | handed back while it moves; a rebuild after each drop | exclude it, or unbatch(id) when it is picked up |
| is re-customized many times in a row (a configurator on one building) | every town.customize rebuilds all batches | occasional edits need nothing; otherwise exclude it, or use strict |
const town = await library.assembleScene(sceneJson, { optimize: { exclude: ['windmill-1', 'house-3'] } });
// or { optimize: 'strict' }: only unmodified, clip-free instances are batched; customized ones keep their own meshes<OptimizedScene exclude={['windmill-1']}>
{instances.map((i) => <Asset key={i.instance_id} instanceId={i.instance_id} assetId={i.asset} customize={Object.keys(i.values).length ? i.values : undefined} position={i.position_m} quaternion={i.quaternion_xyzw} />)}
</OptimizedScene>
// or <OptimizedScene preset="strict">- Exclude by instance id. An exclusion names the scene instance, so it still applies after
town.customize(id, …)replaces the instance. SDK 0.5.1 also accepts the instance’sLoadedAsset(with SDK 0.5.0 only an id survivestown.customize). strictkeeps every customized or clip-bearing instance on its own meshes and still batches the repeated tiles and props; it saves fewer draw calls than the defaultlarge-scene.- Picking needs no exclusion. Raw raycasts return exactly the hits they return without optimization, and
town.optimization.pick(raycaster)resolves the first drawn hit to its instance and mounted part.
GLB optimization: nothing shipped
Quantized and meshopt-compressed variants of the kit's 30 reference GLBs were built and measured. Decision: no variant is shipped — not every condition holds for any variant (see conditions); the reference GLB stays the only published file. Loading a variant (loadAsset(ref, { variant: 'optimized' })) is refused with VARIANT_NOT_AVAILABLE.
Host GPU
ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Pro, Unspecified Version). References: decode 24.6 ms for all 30, town frame 2.2 ms; variants: decode 25.9 / 26.6 / 26.8 / 26.8 ms, frame 2.2 / 2.2 / 2.2 / 2.3 ms (in the table's order). Measured 2026-10-26.
| Variant | Raw | gzip | brotli | Pixels | Vertex shift |
|---|---|---|---|---|---|
| Reference GLBs 30, published | 3.76 MB | 744 KB | 513 KB | — | — |
quantize | 2.72 MB −27.6 % | 832 KB +11.9 % | 549 KB +6.9 % | 0.186 % | 0.62 mm |
meshopt-lossless | 2.46 MB −34.4 % | 1.02 MB +39.7 % | 889 KB +73.2 % | 0.000 % | 0 mm |
meshopt | 1.27 MB −66.3 % | 584 KB −21.5 % | 514 KB +0.1 % | 0.186 % | 0.62 mm |
meshopt-high | 1.07 MB −71.6 % | 483 KB −35.0 % | 434 KB −15.4 % | 0.186 % | 0.62 mm |
Conditions each variant does not meet
quantize: KHR_mesh_quantization: positions 14 bit (per mesh volume), normals 10 bit; no decoderbytes-smaller: raw 2853108 vs 3939716 B (-27.6 %), gzip 852295 vs 761807 (11.9 %), brotli 561970 vs 525815 (6.9 %)image-equivalent: 0.1860 % of pixels differ by more than 16/255 from the reference render (max delta 157)geometry-within-slot-tolerance:largest vertex displacement 0.62 mm (normals 0.087 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part… Show all
largest vertex displacement 0.62 mm (normals 0.087 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part asset, i.e. 0.05 mm per side
meshopt-lossless: EXT_meshopt_compression codec only (no quantization, no reorder): bit-exact data; MeshoptDecoder requiredbytes-smaller: raw 2584328 vs 3939716 B (-34.4 %), gzip 1064515 vs 761807 (39.7 %), brotli 910468 vs 525815 (73.2 %)no-decoder-needed:needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED /… Show all
needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED / VARIANT_NOT_AVAILABLE) and would need a decoder path plus tests
meshopt: gltf-transform meshopt level 'medium': reorder + quantize (14/10 bit) + codec; MeshoptDecoder requiredbytes-smaller: raw 1328268 vs 3939716 B (-66.3 %), gzip 598141 vs 761807 (-21.5 %), brotli 526596 vs 525815 (0.1 %)image-equivalent: 0.1860 % of pixels differ by more than 16/255 from the reference render (max delta 157)geometry-within-slot-tolerance:largest vertex displacement 0.62 mm (normals 0.087 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part… Show all
largest vertex displacement 0.62 mm (normals 0.087 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part asset, i.e. 0.05 mm per sideno-decoder-needed:needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED /… Show all
needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED / VARIANT_NOT_AVAILABLE) and would need a decoder path plus tests
meshopt-high: gltf-transform meshopt level 'high': as medium + 8-bit octahedral normals, filtered animation data; MeshoptDecoder requiredimage-equivalent: 0.1860 % of pixels differ by more than 16/255 from the reference render (max delta 157)animations:clips unchanged by name and target; largest keyframe rotation change 0.0036 deg (limit 0.01), largest translation/scale/weight change 0.000453 (limit… Show all
clips unchanged by name and target; largest keyframe rotation change 0.0036 deg (limit 0.01), largest translation/scale/weight change 0.000453 (limit 1e-6)geometry-within-slot-tolerance:largest vertex displacement 0.62 mm (normals 1.025 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part… Show all
largest vertex displacement 0.62 mm (normals 1.025 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part asset, i.e. 0.05 mm per sideno-decoder-needed:needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED /… Show all
needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED / VARIANT_NOT_AVAILABLE) and would need a decoder path plus tests
Publishing conditions
- Passed
reference-unchanged: all 30 reference GLBs re-hashed after the run: equal to their manifest pins (they were only read) - Failed
own-sha256-and-manifest-entry: Publishing a variant adds an entry to the asset manifest, which changes the bytes a published version pins. A variant therefore ships as a new asset version, never as an edit of an existing one. - Failed
variant-passes-its-own-qa: The quality gates validate the reference GLB only. No gate validates an optimized variant yet (decoder setup, slot consistency on quantized geometry, customization on the variant).
Software rendering (SwiftShader) run (same decision: nothing shipped)
ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver), measured 2026-10-26. not every condition holds for any variant (see conditions); the reference GLB stays the only published file.
| Variant | Raw | gzip | brotli | Pixels | Vertex shift |
|---|---|---|---|---|---|
| Reference GLBs 30, published | 3.76 MB | 744 KB | 513 KB | — | — |
quantize | 2.72 MB −27.6 % | 832 KB +11.9 % | 549 KB +6.9 % | 0.130 % | 0.62 mm |
meshopt-lossless | 2.46 MB −34.4 % | 1.02 MB +39.7 % | 889 KB +73.2 % | 0.000 % | 0 mm |
meshopt | 1.27 MB −66.3 % | 584 KB −21.5 % | 514 KB +0.1 % | 0.130 % | 0.62 mm |
meshopt-high | 1.07 MB −71.6 % | 483 KB −35.0 % | 434 KB −15.4 % | 0.130 % | 0.62 mm |
Conditions each variant does not meet
quantize: KHR_mesh_quantization: positions 14 bit (per mesh volume), normals 10 bit; no decoderbytes-smaller: raw 2853108 vs 3939716 B (-27.6 %), gzip 852295 vs 761807 (11.9 %), brotli 561970 vs 525815 (6.9 %)image-equivalent: 0.1299 % of pixels differ by more than 16/255 from the reference render (max delta 157)geometry-within-slot-tolerance:largest vertex displacement 0.62 mm (normals 0.087 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part… Show all
largest vertex displacement 0.62 mm (normals 0.087 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part asset, i.e. 0.05 mm per side
meshopt-lossless: EXT_meshopt_compression codec only (no quantization, no reorder): bit-exact data; MeshoptDecoder requiredbytes-smaller: raw 2584328 vs 3939716 B (-34.4 %), gzip 1064515 vs 761807 (39.7 %), brotli 910468 vs 525815 (73.2 %)no-decoder-needed:needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED /… Show all
needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED / VARIANT_NOT_AVAILABLE) and would need a decoder path plus tests
meshopt: gltf-transform meshopt level 'medium': reorder + quantize (14/10 bit) + codec; MeshoptDecoder requiredbytes-smaller: raw 1328268 vs 3939716 B (-66.3 %), gzip 598141 vs 761807 (-21.5 %), brotli 526596 vs 525815 (0.1 %)image-equivalent: 0.1299 % of pixels differ by more than 16/255 from the reference render (max delta 157)geometry-within-slot-tolerance:largest vertex displacement 0.62 mm (normals 0.087 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part… Show all
largest vertex displacement 0.62 mm (normals 0.087 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part asset, i.e. 0.05 mm per sideno-decoder-needed:needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED /… Show all
needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED / VARIANT_NOT_AVAILABLE) and would need a decoder path plus tests
meshopt-high: gltf-transform meshopt level 'high': as medium + 8-bit octahedral normals, filtered animation data; MeshoptDecoder requiredimage-equivalent: 0.1297 % of pixels differ by more than 16/255 from the reference render (max delta 157)animations:clips unchanged by name and target; largest keyframe rotation change 0.0036 deg (limit 0.01), largest translation/scale/weight change 0.000453 (limit… Show all
clips unchanged by name and target; largest keyframe rotation change 0.0036 deg (limit 0.01), largest translation/scale/weight change 0.000453 (limit 1e-6)geometry-within-slot-tolerance:largest vertex displacement 0.62 mm (normals 1.025 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part… Show all
largest vertex displacement 0.62 mm (normals 1.025 deg); the published slot-consistency gate allows 0.1 mm between a host's baked part and the part asset, i.e. 0.05 mm per sideno-decoder-needed:needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED /… Show all
needs MeshoptDecoder (three/examples/jsm/libs/meshopt_decoder.module.js, 29 KB, self-hostable); the SDK loads baseline GLBs only (DECODER_REQUIRED / VARIANT_NOT_AVAILABLE) and would need a decoder path plus tests
Demo budgets of the explorable town
Measured on the vanilla demo and the R3F example (161 instances) with the explicit recipe below. The budgets describe the shipped scene, not yours: your customizations change the totals. No mobile performance is claimed: real devices were not measured.
Host GPU Passed
ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Pro, Unspecified Version), Apple M2 Pro, 1280x800, measured 2026-10-26.
| Budget | Measured | Limit | Status |
|---|---|---|---|
| Triangles drawn (highest) vanilla demo | 138,034 | 150,000 | Passed |
| Draw calls (highest along the camera path) vanilla demo | 148 | 200 | Passed |
| Transferred until ready vanilla demo | 5.30 MB | 12.0 MB | Passed |
| Triangles drawn (highest) R3F example | 138,034 | 150,000 | Passed |
| Draw calls (highest along the camera path) R3F example | 148 | 200 | Passed |
| Transferred until ready R3F example | 5.45 MB | 12.0 MB | Passed |
Draw calls by setup (max over the camera path)
| Vanilla demo, no instancing | 621 |
| Vanilla demo, instanceStatic with SDK defaults | 260 |
| Vanilla demo, instanceStatic with the documented opt-ins | 148 |
| R3F example, no instancing, live shadow map | 1,241 |
| R3F example, instanceStatic + static shadow map | 148 |
Software rendering (SwiftShader) Passed
ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver), Apple M2 Pro, 1280x800, measured 2026-10-26.
| Budget | Measured | Limit | Status |
|---|---|---|---|
| Triangles drawn (highest) vanilla demo | 138,034 | 150,000 | Passed |
| Draw calls (highest along the camera path) vanilla demo | 148 | 200 | Passed |
| Transferred until ready vanilla demo | 5.31 MB | 12.0 MB | Passed |
| Triangles drawn (highest) R3F example | 138,034 | 150,000 | Passed |
| Draw calls (highest along the camera path) R3F example | 148 | 200 | Passed |
| Transferred until ready R3F example | 5.45 MB | 12.0 MB | Passed |
Draw calls by setup (max over the camera path)
| Vanilla demo, no instancing | 621 |
| Vanilla demo, instanceStatic with SDK defaults | 260 |
| Vanilla demo, instanceStatic with the documented opt-ins | 148 |
| R3F example, no instancing, live shadow map | 1,241 |
| R3F example, instanceStatic + static shadow map | 148 |
The explicit recipe
{ optimize: true } is this recipe in one option, plus automatic hand-back and re-batching. The explicit form still works and keeps its behaviour: batched meshes snapshot transforms, so call unbatch(id) before you change an instance yourself. An instance is batched by at most one helper at a time.
import { instanceStatic } from '@agent-ready-assets/runtime-three';
const town = await library.assembleScene(sceneJson);
const batches = instanceStatic(town, { includeCustomized: true, includeAnimatable: true });
scene.add(town.group);
renderer.shadowMap.autoUpdate = false; // set renderer.shadowMap.needsUpdate = true when something changes
batches.stats(); // { drawsBefore, drawsAfter, triangles, ... }
// before moving or hiding one instance yourself: batches.unbatch(id); on teardown: batches.dispose(); town.release();Instancing reduces draw calls only; triangles are counted per instance either way. Measure your own scene with renderer.info.render and stats().
What a choice costs
| Asset · choice | Triangles | Draw calls | Instanced |
|---|---|---|---|
stylized-town/bench-01@0.4.0 defaults | 1,012 | 6 | 6 |
| armrests: false | 836 (-176) | 4 | 4 |
stylized-town/cafe-01@0.1.0 defaults | 6,020 | 14 | 14 |
| front-door: door-01 | 5,888 (-132) | 14 | 14 |
| awning: false | 5,648 (-372) | 12 | 12 |
| terrace: false | 3,924 (-2,096) | 11 | 11 |
stylized-town/house-01@0.2.0 defaults | 6,144 | 13 | 13 |
| front-door: door-02 | 6,276 (+132) | 15 | 15 |
| window-style: window-02 | 24,624 (+18,480) | 56 | 14 |
| roof: roof-hip-01 | 7,320 (+1,176) | 13 | 13 |
| chimney: false | 6,056 (-88) | 12 | 12 |
stylized-town/house-02@0.1.0 defaults | 14,608 | 14 | 14 |
| front-door: door-01 | 14,476 (-132) | 14 | 14 |
| window-style: window-01 | 5,984 (-8,624) | 25 | 13 |
| roof: roof-gable-01 | 13,432 (-1,176) | 14 | 14 |
stylized-town/shop-01@0.2.0 defaults | 3,264 | 11 | 11 |
| front-door: door-01 | 3,132 (-132) | 11 | 11 |
| awning: false | 2,892 (-372) | 9 | 9 |
stylized-town/street-lamp-01@0.3.0 defaults | 1,158 | 5 | 5 |
| light: false | 1,114 (-44) | 4 | 4 |
stylized-town/townhall-01@0.1.0 defaults | 10,024 | 9 | 9 |
| flag: false | 9,888 (-136) | 7 | 7 |