Unity’s static batching system decreases draw call overhead by combining static meshes into one large mesh buffer, which allows the renderer to draw each object by setting a start and end index in the buffer to draw from rather than binding a new mesh for every call. However, it also has the capability to massively reduce the total number of drawcalls, but to what extent it does this (if at all) is completely dependent on the order the meshes are appended in the combined buffer.
This optimization happens when it finds multiple draws using the same shader variant and shader inputs. If their vertices form a continuous range in the combined vertex buffer, all meshes are drawn in a single call. This decreases CPU overhead but has the potential to increase GPU overhead by causing overdraw due to a lack of front-to-back sorting. If reducing CPU overhead is our goal, the ideal case is each visible material will only take one call to draw all meshes using it. What stops this from happening is if the objects of the same shader/inputs aren’t continuous in the buffer. Then the renderer has to issue a new drawcall at each gap to bind a new start and end index.
As the static batching system sorts renderers by material before appending them, the most common cause of a gap is simply culled renderers. Thus, in order to optimize the drawcall reduction we want the renderers to also be sorted spatially such that the visible renderers within the current view-frustrum and occlusion zone are close neighbors in the combined index buffer.
Prior to 2023, the batching system made no attempt to spatially sort renderers. As a practical matter, most renderers in any scene of a reasonable size will be culled. Thus there are almost certainly gaps between every draw’s indices and the drawcall reduction never happens. This issue has been pointed out on this forum (See: Unity's static batching: why does it shoot itself in the foot? ). The forum-member Apkdev created a patch that used some hacky methods to redirect Unity’s static batching mesh-sorting function to a custom one that did a Hilbert-Curve index sort, one of the best methods for spatially sorting 2d points. Unity responded, and “patched” it in 2023. The problem is instead of copying Apkdev’s correct implementation, they literally copy-pasted another form member’s suggested sort which is so hilariously wrong as to be ludicrous.
// simple "spatial" sort on axes (prefer x/z plane)
auto lPos = lhs.localToWorld.GetPosition();
auto rPos = rhs.localToWorld.GetPosition();
if (lPos.x != rPos.x)
return lPos.x < rPos.x;
if (lPos.z != rPos.z)
return lPos.z < rPos.z;
if (lPos.y != rPos.y)
return lPos.y < rPos.y;
If it isn’t blatantly obvious, this only sorts on the X-axis unless objects have exactly the same X value, then the same on Z then Y. This is not a spatial sort in any form, and does not help in the vast, vast majority of cases. To make matters worse, the code responsible for the sort was moved to C++, making it impossible to patch the function like we used to.
Proposal:
Replace the wrong “treat each axis as 1d” sort with a Hilbert Index sort. Such a sort should operate on the render’s bounds center rather than the transform. Additionally, during the material sort a renderer’s material submeshes should be split and sorted separately to prevent multi-material meshes from automatically breaking continuity in the index buffer.
Here’s a useful reference on implementing a hilbert index sort, and Apkdev’s implementation (which is MIT licensed):
https://doc.cgal.org/latest/Spatial_sorting/index.html
Additionally, it would be very useful to have an API to override the sorting method with a user-defined one or an API to resort the results of the default method. The Hilbert curve sort is good, but can fall apart in certain situations. For example having a thin wall or floor between rooms can result in objects on either side being grouped together. In this case it might be useful to make your own sort that operates on user define zones where objects within each zone are sorted together, then the zones are sorted and their meshes appended. This would allow the user to guarantee each room’s meshes are always continuous, and that meshes of the connected rooms are also neighbors.
The ultimate solution however is to use indirect draws for graphics API’s that support it (DX12, Vulkan). This allows drawing each bin of shader variant+shader input data combination with a single an indirect draw call regardless of gaps or sorting between submeshes. This guarantees the absolute ideal state for reducing CPU overhead no matter what, and also allows for the sorting the draws within the indirect buffer based on Z distance to simultaneously achieve optimal overdraw reduction.