The real limit on custom models is not download size (that compresses ~5:1 in the shipped bundle; a 190 MB bundle zips to ~69 MB, and mod.io’s soft limit is 100 MB with generous headroom above). For unit models the real limit is a GPU vertex buffer the game packs every skinned mesh into — this page records what that buffer actually is, measured live, and how to budget against it. But it is only one of THREE separate draw-budget systems (units / districts / terrain), each limiting something different — terrain, notably, stores no vertices at all. The full map is in Three pipelines, three different budgets below; the sections in between are the unit pipeline’s detail.
Amplitude.Graphics.FxComponentMeshContentManager)FxOneMeshStruct[]), filled by a running
currentVertexIndex / currentIndexIndex / currentMeshAddedCount cursor.DrawMeshInstancedIndirect — each unique mesh is
stored once and drawn for every pawn of that type. 1 tank or 100 tanks of the same type = one
entry. Units on screen are irrelevant to the budget."Unable to store mesh … vertex buffer is not large enough" and drops the mesh — you get
missing / see-through geometry in-game (this is the vanished-rotor-mast class of bug), never a crash.baseVertexBufferSize = 100000 / baseIndexBufferSize = 250000 / maxMeshCount = 256 constants
in the decompiled source are only default initializers — at runtime the game sizes the layers
far larger (see measured values below). Do not trust the source defaults; measure.maxMeshTriangleCount — a
per-mesh triangle ceiling whose overflow is even nastier than the buffer’s: quads beyond it are
silently truncated at encode (FillIndexBufferContentFromQuad clamps to the cap) — the model
renders with holes and nothing is logged. But the shipped layer data sets it to 0 = unlimited on
every layer (verified live), so a single unit’s practical limit is simply the free space left in
the shared pool.Three layers exist; custom models land in the pawn layer (FXMeshLayerIndex = 2):
| layer | name | vertices | indices | meshes | maxTris/mesh |
|---|---|---|---|---|---|
| 0 | Visual |
2,995,550 / 3,000,000 (99%)¹ | 5.73M / 9M (63%) | 4606 / 8000 | unlimited |
| 1 | Emitter |
2,700 / 10,000 (27%) | 10k / 90k (11%) | 20 / 2000 | unlimited |
| 2 | MeshWithSkeletonParticleIndexBuffer ← custom models |
694,126 / 1,000,000 (69%) | 1.48M / 6.5M (22%) | 695 / 2500 | unlimited |
¹ before DistrictBufferHeadroom; with the district headroom set it reads e.g. 5,000,000.
The pawn-layer ceiling is ~1,000,000 vertices / 6,500,000 indices / 2,500 meshes — 10× the source
default. Vertices are the binding constraint (69% used vs 22% indices / 28% mesh slots). Layer 0
Visual is the shared building/district buffer (the district axis draws from it), unrelated to
your unit models.
[Buffers] BufferOverridesAll four limits are plain fields set at layer creation, and the plugin can override any of them
(same Harmony seam as DistrictBufferHeadroom, generalized):
[Buffers]
BufferOverrides = MeshWithSkeleton:verts=+1000000,idx=+2000000,meshes=+1000
<layerNameSubstring>:verts=+N,idx=+N,meshes=+N,maxtris=N, semicolon-separated for several
layers. verts/idx/meshes add to the buffer sizes; maxtris sets the per-mesh cap
absolutely (0 = unlimited). Layer names come from the Mesh Budget dump.[Buffers] '<layer>' baseVertexBufferSize: 1000000 -> 2000000.verts=+1000000 ≈ +28 MB, idx=+2000000
≈ +8 MB. The shader reads buffer sizes dynamically, so the change is transparent.With the override above, a 100k-vert hero unit is entirely practical — the shipped default pool already fits one today (~300k free late-game); the override just restores comfortable headroom for the rest of the roster.
buffer used = Σ (vertices of each distinct loaded model type) — independent of instance count.
Do not confuse that vertex total with Model Factory’s Reduce to ~tris field. The field is a Blender triangle
ceiling; it is not a vertex target and there is no stable tris→verts conversion. UV seams, material boundaries, skin
weights, and import splitting can make the baked vertex count substantially higher than the triangle count. Use the
bake log for the model’s actual verts= cost and F8 for the roster-wide total.
Originally we assumed “era-clustering”: that a late-game save loads more of the roster at once. The measurements say otherwise — a brand-new game reads 701,866 verts (70%), virtually identical to a Contemporary save’s 694,126 (69%). The pawn pool is filled at load with (nearly) the full roster’s meshes, regardless of era. Consequences:
BufferOverrides (above) and stop worrying.The plugin exposes the live buffer usage:
BepInEx/LogOutput.log
([Budget] lines).Spawning 10 more of the same unit won’t move the numbers (instancing — copies are free).
There is no single “vertex limit” in this game. Three separate rendering systems each budget something different, and they fail the same way (silently not drawn) for different reasons. Everything below is measured, not assumed:
| Pipeline | What is stored | Pool limit (shared) | Per-mesh limit | Raise it with |
|---|---|---|---|---|
| Units (pawns) | baked skinned meshes, one copy per TYPE (instances free) | ~1,000,000 verts (pawn layer; ~700k used by the roster) | 16,320 quads per FRAGMENT (compiled shader stride) | [Buffers] BufferOverrides for the pool; the 0.5.7 multi-fragment split (both bake paths since 2026-09-20) for the per-mesh cap |
THE TWO CEILINGS PULL AGAINST EACH OTHER (2026-09-20, the steam frigate). The split is the cure for the per-fragment quad ceiling, and it is paid for out of the pool: each chunk duplicates the vertices on its seam. How much depends entirely on where the cuts fall — that ship went 99,676 → 125,369 verts (+26%) as four static fragments, but 99,676 → 100,648 (+1%) as four animated ones. Read your own bake’s
BAKED MESHlines rather than assuming either figure. The pool is shared by every unit type, and when it fills the game stops uploading meshes entirely — units and districts both stop drawing, with no error. That day’s buffer was already doubled to 2,000,000 byBufferOverridesand sat at 1,999,968; one static re-bake of one ship was the straw. Read the fill with F8 (L2 … verts %), and raise the pool withBufferOverrides = MeshWithSkeleton:verts=+2000000(roughly +48 MB VRAM per million) before splitting a second large model. Reducing triangles pays both ceilings at once; splitting pays one by spending the other. | Districts / buildings | baked static meshes in the sharedVisuallayer | 3,000,000 verts, ~99% full late-game — the tightest real vertex wall | 255 sub-particles × PPC (PPC dynamic, auto-boosted since 0.5.7) |DistrictBufferHeadroomfor the pool;DistrictMeshDensityBoostfloor for the ceiling — District-Visuals | | Terrain tiles | NO stored vertices at all —ProceduralTerrainRendererGENERATES the hex geometry on the GPU every frame (visibility kernel → repack → draw-procedural + tessellation) and throws it away | n/a — vertices are manufactured per frame | 10,000 visible hexagons per frame (post-culling) and 800,000 draw commands, both plain ints on the technical-settings asset |[Terrain] TerrainHexagonBufferMultiplier(0.5.7, experimental — section below) |
The practical consequences:
Recorded 2026-09-13 after the multi-fragment unit split shipped (0.5.7): a community report (Discord) says map tiles beyond roughly 21,000 stop rendering, that each tile draws as 3 parts, and asks whether the unit trick generalizes. Everything below is a hypothesis on one probe session — treat it per the Review-Backlog rules (re-verify before acting), it is not a finding.
Amplitude.Mercury.Terrain.ProceduralTerrainRenderer is C# — GPU-indirect (visibility/repack/draw
compute kernels) but driven entirely from patchable code. Its CreateOrResizeVisibleHexagonsBuffer /
CreateOrResizeDrawCommandsBuffer size their buffers from two plain ints on the loaded
TerrainRendererTechnicalSettings asset (IL read, token-resolved; no clamp constant in the renderer).[Terrain] probe line, 2026-09-13): VisibleHexagonsBufferSize = 10,000,
DrawCommandBufferSize = 800,000. Not 65,536 — so the clean “ushort capacity” theory is dead in
its original form. Two live theories remain:
WorldMapProviderHelper.ImportFrom’s 65536s are capability FLAG BITS; Matching.BakedElement’s
65535 is the pattern-library sentinel (NoMatchingEntryIndex), not a tile count.[Terrain] TerrainHexagonBufferMultiplier (plugin config, default 1 =
vanilla) multiplies both settings ints before buffer creation, and the probe line logs the shipped
values every launch. The decisive field test: a >10k-visible-tile view (huge map, max zoom-out)
with the multiplier at 1 vs 4 — if missing far tiles appear, theory 1 is confirmed and the ceiling is
effectively broken; if nothing changes, hunt theory 2’s ushort in the compute kernels’ data layout.