Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. We have updated the language to the Editor Terms based on feedback from our employees and community. Learn more.
    Dismiss Notice
  3. Join us on November 16th, 2023, between 1 pm and 9 pm CET for Ask the Experts Online on Discord and on Unity Discussions.
    Dismiss Notice

Static Batching vs. StaticBatchingUtility vs. Mesh.Combine - when to use which?

Discussion in 'Scripting' started by Xarbrough, Feb 1, 2017.

?

General thoughts on batching

  1. Use Unity's static batching as much as possible

    38.6%
  2. Custom combine and manual workflow are better

    40.9%
  3. Automated tests with a scientific touch are best

    11.4%
  4. Eyeballing it is good enough for games

    9.1%
  5. Good results require an impossible to describe mixture of all approaches

    22.7%
Multiple votes are allowed.
  1. Xarbrough

    Xarbrough

    Joined:
    Dec 11, 2014
    Posts:
    1,184
    I'm looking for some advice in which cases to use which tool to reduce the amount of draw calls in a rendered scene. I know the basics, but I'd like to dig deeper into the topic, beyond what the documentation provides.

    Sources (Unity 5.5)
    https://docs.unity3d.com/Manual/DrawCallBatching.html
    https://docs.unity3d.com/ScriptReference/StaticBatchingUtility.html

    Static Batching vs. StaticBatchingUtility
    Does the utility method do the same thing on GameObjects as settings the Batching Static flag in the inspector? As far as I understand, I can let Unity do everything at build time, but when I have procedurally generated meshes, I can use the utility call to perform batching when my mesh is generated at runtime. Is there any other difference or something to be aware of? Would I have any reason to use StaticBatchingUtility at edit time?

    Static Batching vs. manual Mesh.Combine
    Given that all meshes already exist at build time, what reasons could I have to prefer manual batching and what downsides would it have? Generally, I would only create a custom batching tool, if it could be run during the build process, so it wouldn't interfere with level design (just like Unity's batching works).
    • Static Batching requires the same material on renderers within a batch. Does this mean, that multi-material renderers will not be batched at all or does Unity take apart the individual submeshes?
    • Creating custom texture atlasses appears to be a solution to provide more batching-potential for any solution. Say, I have created a tool which creates a well-suited compromise of shared materials vs potential memory overhead - would the be any performance difference between combining the meshes manually at edit time vs using the static editor flags?
    • Shadow casters are batched differently than regular geometry. Does this mean, Unity handles everything for me or can I achieve better results by manually separating drawn geometry from shadows? This would mean, I'd have to create a copy of each mesh and then batch both groups separately. In the end, I would be able to create larger groups for shadow casters, because all opaque materials can share the same. Is this something Unity already takes advantage of?
    • Static Batching already handles occlusion and frustum culling. Manual batches would need to be separated into smaller, local chunks, to provide a similar effect. Is this generally desired or could I possibly want giant shadow batches and smaller geometry chunks in combination to get even more out of it?
    • Does it make sense to batch physics mesh colliders together? In theory it would be possible to combine all mesh colliders into a single giant mesh (split it up into groups of 64k). Would it be more efficient to send this big chunk over to physics processing or could it even mean another bottle neck? As far as I know, all static colliders aka "the physics world" need to be build as one big thing anyway, which is why it's so bad to move a static collider, as it means the whole world needs to be recalculated.
    Summary
    I'm trying to get a feeling for when it might make sense to create a custom batching solution vs using Unity's builtin tools; not only from a performance perspective, but also workflow.

    Of course, the best thing to do would be to profile the final game and decide upon each permutation. I do honor my own testing, but I believe there a understandable reasons, why it's not always possible to judge by experiment: When writing a tool for future projects and others to use, I don't know their specific requirements, but I might want to provide them with a custom solution. Often, measurements are not precise enough so that they can be isolated down to the real cause, e.g. reducing the number of draw calls vs increasing memory might make sense in one level on a specific platform, but in different levels and other platforms and might not work. Add to this a few dozen possible combinations of settings and it becomes very difficult to judge. How do you guys deal with these issues? Just with regular profiling concrete scenes and changes stuff until it gets better or do you create automated tests, which record data and then compare?

    PS, I'd be happy to post more of my own findings, if others are interested, hence the elaborated post. ;)
     
    ron-bohn likes this.
  2. VengeanceDesign

    VengeanceDesign

    Joined:
    Sep 7, 2014
    Posts:
    84
    About the physics collider thing, I know from experience that extremely broken up meshes are bad. You discover this when it comes to SketchUp3D models, which often have thousands of polygons broken up into sections with a few polygons. If you don't merge the meshes and just import them straight, you can have insane performance problems. Breaking up a mesh lets you have more sections that can be culled from raycasts, but it also means more time put into culling calculations. The same problem happens with camera culling for very broken up meshes. Obviously you shouldn't be using Sketchup3d models for games but it will let you get pretty specific models for free that aren't available free anywhere else.

    I personally only break up my larger models like buildings and even then it's normally just a few sections. I set them as static and the engine culls off sections of the building, saving rendering time. In the case of mobile games I also use blender to "decimate" models by removing faces that show detail that is unnecessary.
     
    Xarbrough likes this.
  3. Cross22

    Cross22

    Joined:
    Sep 26, 2014
    Posts:
    22
    Excellent questions!
    Just for the static batching case I am wondering if there is a benefit to combining static geometry in the modeling software or if Unity's batching is sufficient. In the later case I would still have a large number of GameObjects in the hierarchy, but aside from that is there any other performance impact?
     
    arhofmann likes this.
  4. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    8,380
    So I'm not a fan of the poll choices... I'd say it's a combination of 1 and 2. It's not impossible, it doesn't require science, just some thought.

    So... for one, yes, USE STATIC BATCHING. May you do it automatically or manually, use it, it gives great performance gains.

    But if you should use it manually or not... well. I'll say this, we recently had some issues with performance as it goes with our static batching and lighting.

    Our current game is built all out of tilesets that are put together procedurally through code. This means a room is technically multiple models:


    This was costing a lot of draw cycles, so of course needed to be statically batched.

    We then started adding lots of lights, but this would cause lots of slow down on lower end machines. So we reduced the number of lights that lit a pixel with forward rendering:
    Project Settings->QualitySettings->Pixel Light Count

    Down side was that because our tiles were technically different objects we got this as a result, even when statically batched:


    Note the shadow difference from wall segement to wall segment. Because the wall on the left has multiple lights near it, it gets fully lit. But the next wall tile is picking up lights on the other side of the room and appears darker.

    So we needed to weld the verts of these tiles together instead. So I slapped together this script:

    Code (csharp):
    1.  
    2. using UnityEngine;
    3. using System.Collections.Generic;
    4.  
    5. using com.spacepuppy;
    6. using com.spacepuppy.Collections;
    7.  
    8. using com.mansion;
    9.  
    10. [Infobox("Does not work on static meshes, make sure all meshes you want included are not flagged as 'batched' in their static settings.")]
    11. public class MeshMerger : MonoBehaviour
    12. {
    13.  
    14.     public enum AlgorithmType
    15.     {
    16.         Disable = -1,
    17.         Unity = 0,
    18.         OldSchool = 1
    19.     }
    20.  
    21.     public AlgorithmType Algorithm;
    22.  
    23.     private void Start()
    24.     {
    25.         switch(this.Algorithm)
    26.         {
    27.             case AlgorithmType.Unity:
    28.                 this.MergeUnityAPI();
    29.                 break;
    30.             case AlgorithmType.OldSchool:
    31.                 this.MergeUsingOldschool();
    32.                 break;
    33.         }
    34.     }
    35.  
    36.     private void MergeUnityAPI()
    37.     {
    38.         var table = new Dictionary<Material, HashSet<MeshFilter>>(ObjectInstanceIDEqualityComparer<Material>.Default);
    39.         foreach (var f in this.GetComponentsInChildren<MeshFilter>())
    40.         {
    41.             if (f.HasTag(Constants.TAG_IGNOREMESHMERGER)) continue;
    42.  
    43.             var mesh = f.sharedMesh;
    44.             if (mesh.GetInstanceID() < 0) continue; //this means it's a combined mesh
    45.  
    46.             var renderer = f.GetComponent<Renderer>();
    47.             if (renderer == null) continue;
    48.  
    49.             var mat = renderer.sharedMaterial;
    50.             HashSet<MeshFilter> grp;
    51.             if (!table.TryGetValue(mat, out grp))
    52.             {
    53.                 grp = new HashSet<MeshFilter>();
    54.                 table.Add(mat, grp);
    55.             }
    56.  
    57.             grp.Add(f);
    58.         }
    59.  
    60.         foreach (var pair in table)
    61.         {
    62.             var mat = pair.Key;
    63.             var grp = pair.Value;
    64.             var combine = new CombineInstance[grp.Count];
    65.             var worldToLocal = this.transform.worldToLocalMatrix;
    66.  
    67.             int i = 0;
    68.             foreach (var f in grp)
    69.             {
    70.                 combine[i].mesh = f.sharedMesh;
    71.                 combine[i].transform = worldToLocal * f.transform.localToWorldMatrix;
    72.                 f.gameObject.SetActive(false);
    73.                 i++;
    74.             }
    75.  
    76.             // hook up the mesh renderer
    77.             var go = new GameObject("Mesh*" + mat.name);
    78.             go.transform.parent = this.transform;
    79.             go.transform.localPosition = Vector3.zero;
    80.             go.transform.localRotation = Quaternion.identity;
    81.             go.transform.localScale = Vector3.one;
    82.  
    83.             var me = new Mesh();
    84.             go.AddComponent<MeshFilter>().sharedMesh = me;
    85.             me.CombineMeshes(combine, true, true);
    86.             go.AddComponent<MeshRenderer>().sharedMaterial = mat;
    87.         }
    88.  
    89.         //cleanup
    90.         table.Clear();
    91.         table = null;
    92.         System.GC.Collect();
    93.     }
    94.  
    95.  
    96.  
    97.  
    98.     private void MergeUsingOldschool()
    99.     {
    100.         var table = new Dictionary<Material, Group>(ObjectInstanceIDEqualityComparer<Material>.Default);
    101.         foreach(var f in this.GetComponentsInChildren<MeshFilter>())
    102.         {
    103.             var mesh = f.sharedMesh;
    104.             if (mesh.GetInstanceID() < 0) continue; //this means it's a combined mesh
    105.  
    106.             var renderer = f.GetComponent<Renderer>();
    107.             if (renderer == null) continue;
    108.          
    109.  
    110.             var mat = renderer.sharedMaterial;
    111.             Group grp;
    112.             if(!table.TryGetValue(mat, out grp))
    113.             {
    114.                 grp = new Group();
    115.                 grp.material = mat;
    116.                 table.Add(mat, grp);
    117.             }
    118.  
    119.             grp.filters.Add(f);
    120.  
    121.             grp.vertCount += mesh.vertices.Length;
    122.             grp.normCount += mesh.normals.Length;
    123.             grp.triCount += mesh.triangles.Length;
    124.             grp.uvCount += mesh.uv.Length;
    125.         }
    126.  
    127.         foreach(var grp in table.Values)
    128.         {
    129.             // allocate arrays
    130.             Vector3[] verts = new Vector3[grp.vertCount];
    131.             Vector3[] norms = new Vector3[grp.normCount];
    132.             Matrix4x4[] bindPoses = new Matrix4x4[grp.filters.Count];
    133.             BoneWeight[] weights = new BoneWeight[grp.vertCount];
    134.             int[] tris = new int[grp.triCount];
    135.             Vector2[] uvs = new Vector2[grp.uvCount];
    136.  
    137.             int vertOffset = 0;
    138.             int normOffset = 0;
    139.             int triOffset = 0;
    140.             int uvOffset = 0;
    141.             int meshOffset = 0;
    142.             var worldToLocal = this.transform.worldToLocalMatrix;
    143.  
    144.             // merge the meshes and set up bones
    145.             foreach (MeshFilter mf in grp.filters)
    146.             {
    147.                 var mesh = mf.sharedMesh;
    148.  
    149.                 foreach (int i in mesh.triangles)
    150.                     tris[triOffset++] = i + vertOffset;
    151.              
    152.                 bindPoses[meshOffset] = Matrix4x4.identity;
    153.  
    154.                 var matrix = worldToLocal * mf.transform.localToWorldMatrix;
    155.  
    156.                 foreach (Vector3 v in mesh.vertices)
    157.                 {
    158.                     weights[vertOffset].weight0 = 1.0f;
    159.                     weights[vertOffset].boneIndex0 = meshOffset;
    160.                     verts[vertOffset++] = matrix.MultiplyPoint(v);
    161.                 }
    162.  
    163.                 foreach (Vector3 n in mesh.normals)
    164.                     norms[normOffset++] = matrix.MultiplyVector(n);
    165.  
    166.                 foreach (Vector2 uv in mesh.uv)
    167.                     uvs[uvOffset++] = uv;
    168.  
    169.                 meshOffset++;
    170.  
    171.                 MeshRenderer mr = mf.GetComponent<MeshRenderer>();
    172.                 if (mr != null)
    173.                     mr.enabled = false;
    174.             }
    175.  
    176.             // hook up the mesh
    177.             Mesh me = new Mesh();
    178.             me.name = gameObject.name;
    179.             me.vertices = verts;
    180.             me.normals = norms;
    181.             me.boneWeights = weights;
    182.             me.uv = uvs;
    183.             me.triangles = tris;
    184.             me.bindposes = bindPoses;
    185.  
    186.             // hook up the mesh renderer
    187.             var go = new GameObject("Mesh*" + grp.material.name);
    188.             go.transform.parent = this.transform;
    189.             go.transform.localPosition = Vector3.zero;
    190.             go.transform.localRotation = Quaternion.identity;
    191.             go.transform.localScale = Vector3.one;
    192.  
    193.             go.AddComponent<MeshFilter>().sharedMesh = me;
    194.             go.AddComponent<MeshRenderer>().sharedMaterial = grp.material;
    195.         }
    196.  
    197.         //cleanup
    198.         table.Clear();
    199.         table = null;
    200.         System.GC.Collect();
    201.     }
    202.  
    203.     private class Group
    204.     {
    205.         public Material material;
    206.         public HashSet<MeshFilter> filters = new HashSet<MeshFilter>();
    207.  
    208.         public int vertCount = 0;
    209.         public int normCount = 0;
    210.         public int triCount = 0;
    211.         public int uvCount = 0;
    212.     }
    213.  
    214. }
    215.  
    Well really... my artist took it upon himself to go out and find a script to do it for free on the web. And it was glitchy as all hell because it accounted only for one material and deleted all other meshes (cause that's how you do it!), so he asked me to make it work right... and I did. But then was like, wait, unity has this utility built in to do this... so I wrote a method using that instead, but kept the old algorithm so that my artist could play with both and pick which he preferred.

    Anyways... no longer has the problem:



    (note - screencaps are from a debug room that I toss junk into to test if it works... hence junk like a weird floating sphere, and a bathroom sink just in the middle of nowhere)

    My point being that we had to use the manual technique in this situation because the standard static batching that unity does when you tag it doesn't weld the models together.

    So sometimes you just have to be aware of these situations where even though static batching is giving you that performance oomph, you might have to do a little extra because you visually need more.