using System.Collections.Generic;
/// High-performance mesh merger.
/// 1) Accepts an array of root objects.
/// 2) Walks each hierarchy non-recursively and collects MeshRenderers + SkinnedMeshRenderers.
/// 3) Skinned meshes are culled against an optional BoxCollider.
/// 4) All meshes are merged into a single mesh and assigned to this object's MeshFilter.
/// Attach this to the "merger object" that has a MeshFilter + MeshRenderer.
[DisallowMultipleComponent]
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
public sealed class MeshMerger : MonoBehaviour
[Tooltip("Root objects whose hierarchies will be scanned every frame.")]
[SerializeField] private Transform[] roots;
[Tooltip("Optional BoxCollider used to cull SkinnedMeshRenderers by bounds.")]
[SerializeField] private BoxCollider skinnedCullBox;
[Tooltip("Include inactive objects in hierarchy traversal.")]
[SerializeField] private bool includeInactive = false;
/// <summary>Reusable stack to traverse hierarchies without recursion.</summary>
private readonly Stack<Transform> _transformStack = new Stack<Transform>(128);
/// <summary>Static mesh filters (MeshRenderer + MeshFilter).</summary>
private readonly List<MeshFilter> _meshFilters = new List<MeshFilter>(128);
/// <summary>Skinned mesh renderers.</summary>
private readonly List<SkinnedMeshRenderer> _skinnedRenderers = new List<SkinnedMeshRenderer>(64);
/// <summary>Reusable combine buffer – no per-frame allocations once grown.</summary>
private CombineInstance[] _combineBuffer = new CombineInstance[128];
/// <summary>Target mesh where the merged result is stored.</summary>
private Mesh _mergedMesh;
private MeshFilter _targetMeshFilter;
/// <summary>Allow external code to set/override roots in a DOP-style fashion.</summary>
public void SetRoots(Transform[] newRoots)
/// <summary>Optional external control of the culling box.</summary>
public void SetSkinnedCullBox(BoxCollider box)
_targetMeshFilter = GetComponent<MeshFilter>();
// Reuse a single mesh instance for the merged result.
_mergedMesh = _targetMeshFilter.sharedMesh;
name = "MergedMesh (Runtime)"
// Support large vertex counts.
_mergedMesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
_targetMeshFilter.sharedMesh = _mergedMesh;
_mergedMesh.Clear(false);
_mergedMesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
// We do NOT destroy the mesh here, because it may be referenced from assets or pooling.
// If you want full cleanup, uncomment the next line:
// if (_mergedMesh != null) Destroy(_mergedMesh);
private void LateUpdate()
if (_meshFilters.Count == 0 && _skinnedRenderers.Count == 0)
if (_mergedMesh.vertexCount > 0)
_mergedMesh.Clear(false);
// 2) Build combine instances, with SkinnedMeshRenderer culling by box collider
int instanceCount = BuildCombineBuffer();
// After culling, nothing left to draw
if (_mergedMesh.vertexCount > 0)
_mergedMesh.Clear(false);
// 3) Merge – single call into native engine side, very fast, no GC.
_mergedMesh.Clear(false);
_mergedMesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
_mergedMesh.CombineMeshes(_combineBuffer, true, true, false);
/// Traverses all roots and collects MeshFilters and SkinnedMeshRenderers.
/// No recursion, no allocations after warmup.
private void CollectRenderers()
_skinnedRenderers.Clear();
for (int r = 0; r < roots.Length; r++)
Transform root = roots[r];
_transformStack.Push(root);
while (_transformStack.Count > 0)
Transform t = _transformStack.Pop();
if (!includeInactive && !t.gameObject.activeInHierarchy)
goto PUSH_CHILDREN; // skip components but still traverse children if wanted? Here we skip entirely.
// Static mesh: MeshRenderer + MeshFilter
if (t.TryGetComponent<MeshRenderer>(out var mr) &&
t.TryGetComponent<MeshFilter>(out var mf) &&
if (t.TryGetComponent<SkinnedMeshRenderer>(out var smr) &&
_skinnedRenderers.Add(smr);
// Manual child push – no foreach/allocs
int childCount = t.childCount;
for (int i = 0; i < childCount; i++)
_transformStack.Push(t.GetChild(i));
/// Fills the reusable CombineInstance buffer based on current renderers,
/// applying culling rules for SkinnedMeshRenderers.
/// <returns>Number of valid combine instances prepared.</returns>
private int BuildCombineBuffer()
int totalNeeded = _meshFilters.Count + _skinnedRenderers.Count;
// Grow buffer only when needed.
if (_combineBuffer.Length < totalNeeded)
// Rare allocation; steady-state is allocation-free.
int newSize = _combineBuffer.Length;
while (newSize < totalNeeded)
_combineBuffer = new CombineInstance[newSize];
for (int i = 0; i < _meshFilters.Count; i++)
MeshFilter mf = _meshFilters[i];
Mesh mesh = mf.sharedMesh;
if (mesh == null || mesh.vertexCount == 0)
var ci = new CombineInstance
// MeshRenderer is guaranteed to exist here (we checked when collecting).
transform = mf.GetComponent<Renderer>().localToWorldMatrix
_combineBuffer[count++] = ci;
// Skinned meshes (with culling)
var hasCullBox = skinnedCullBox != null;
Bounds boxBounds = hasCullBox ? skinnedCullBox.bounds : default;
for (int i = 0; i < _skinnedRenderers.Count; i++)
SkinnedMeshRenderer smr = _skinnedRenderers[i];
// Step 2: cull against BoxCollider if present
// Require renderer bounds to be fully contained in the box bounds.
if (!boxBounds.Contains(b.min) || !boxBounds.Contains(b.max))
Mesh mesh = smr.sharedMesh;
if (mesh == null || mesh.vertexCount == 0)
// Using sharedMesh + localToWorldMatrix here is fast and allocation-free.
// If you need fully animated vertex positions, you can add an optional
// BakeMesh step into a pooled Mesh and use that mesh instead.
var ci = new CombineInstance
transform = smr.localToWorldMatrix
_combineBuffer[count++] = ci;