# 2. RenderMeshInstanced Batches Part of [[Compute Shaders and GPU Instancing]]. Prev: [[1. Instancing Paths and When to Use Each]]. Next: [[3. Compute Shader Fundamentals]]. ## 2.1 What the API wants From the 6000.5 reference: ```csharp public static void RenderMeshInstanced<T>(in RenderParams rparams, Mesh mesh, int submeshIndex, T[] instanceData, int instanceCount = -1, int startInstance = 0) where T : unmanaged; ``` - `T` is `Matrix4x4` (object-to-world per instance) **or** your struct with a field named exactly `objectToWorld` (`Matrix4x4`), optionally `renderingLayerMask` (`uint`) and `prevObjectToWorld` (`Matrix4x4`, for motion vectors). Other fields are ignored by the renderer but you may keep your own data there. - Overloads take `List<T>` and `NativeArray<T>`. - **Limits:** "a maximum of 1023 instances at once", and with the default objectToWorld + worldToObject data, "a maximum of 511". Add `#pragma instancing_options assumeuniformscaling` to a custom shader to drop worldToObject. We batch at **511** so stock URP materials work. - The material must have **Enable GPU Instancing** on (`Material.enableInstancing`), or Unity throws `InvalidOperationException`. - Unity computes bounds for the batch unless you set `RenderParams.worldBounds`, and culls/sorts the batch **as one object**. That is why we split the field into spatial chunks. ## 2.2 `InstancedRockField.cs` ```csharp using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; /// <summary> /// Scatters N rocks on a ground plane and draws them with RenderMeshInstanced in spatial chunks /// of at most 511 instances. No GameObjects per rock. NOT compiled. /// </summary> public sealed class InstancedRockField : MonoBehaviour { [SerializeField] Mesh rockMesh; [SerializeField] Material rockMaterial; // URP Lit with "Enable GPU Instancing" ticked [SerializeField] int count = 20000; [SerializeField] Vector2 areaSize = new(200, 200); [SerializeField] Vector2 scaleRange = new(0.3f, 1.6f); [SerializeField] float chunkSize = 25f; // world metres per culling chunk [SerializeField] int seed = 1234; [SerializeField] LayerMask groundMask = ~0; [SerializeField] bool castShadows = true; const int MaxPerBatch = 511; struct RockInstance { public Matrix4x4 objectToWorld; // name and type required by RenderMeshInstanced public uint renderingLayerMask; // optional, also recognised by name } sealed class Chunk { public Bounds bounds; public readonly List<RockInstance> rocks = new(); public RockInstance[][] batches; // pre-split arrays of <= 511 } readonly List<Chunk> _chunks = new(); readonly Plane[] _frustum = new Plane[6]; RenderParams _rp; void OnEnable() { if (!rockMaterial.enableInstancing) { Debug.LogWarning(quot;[{name}] Enabling GPU instancing on {rockMaterial.name} for RenderMeshInstanced."); rockMaterial.enableInstancing = true; // edits the asset in the Editor; tick it on the material instead } _rp = new RenderParams(rockMaterial) { shadowCastingMode = castShadows ? ShadowCastingMode.On : ShadowCastingMode.Off, receiveShadows = true, layer = gameObject.layer, lightProbeUsage = LightProbeUsage.BlendProbes }; Build(); } void Build() { _chunks.Clear(); var map = new Dictionary<Vector2Int, Chunk>(); var rng = new System.Random(seed); Vector3 origin = transform.position; for (int i = 0; i < count; i++) { float x = (float)(rng.NextDouble() - 0.5) * areaSize.x; float z = (float)(rng.NextDouble() - 0.5) * areaSize.y; Vector3 p = origin + new Vector3(x, 50f, z); // Snap to the ground once at build time (cheap at startup; never per frame). if (Physics.Raycast(p, Vector3.down, out var hit, 200f, groundMask)) p = hit.point; else p.y = origin.y; float s = Mathf.Lerp(scaleRange.x, scaleRange.y, (float)rng.NextDouble()); var rot = Quaternion.Euler((float)rng.NextDouble() * 20f, (float)rng.NextDouble() * 360f, 0f); var key = new Vector2Int(Mathf.FloorToInt(x / chunkSize), Mathf.FloorToInt(z / chunkSize)); if (!map.TryGetValue(key, out var chunk)) { chunk = new Chunk { bounds = new Bounds(p, Vector3.zero) }; map[key] = chunk; _chunks.Add(chunk); } chunk.rocks.Add(new RockInstance { objectToWorld = Matrix4x4.TRS(p, rot, Vector3.one * s), renderingLayerMask = 1u }); chunk.bounds.Encapsulate(new Bounds(p, Vector3.one * s * 2f)); } foreach (var c in _chunks) { int batchCount = (c.rocks.Count + MaxPerBatch - 1) / MaxPerBatch; c.batches = new RockInstance[batchCount][]; for (int b = 0; b < batchCount; b++) { int start = b * MaxPerBatch; int len = Mathf.Min(MaxPerBatch, c.rocks.Count - start); c.batches[b] = c.rocks.GetRange(start, len).ToArray(); } } } void Update() { // Draw for every camera (Scene view too). For one camera only, set _rp.camera. var cam = Camera.main; bool cull = cam != null; if (cull) GeometryUtility.CalculateFrustumPlanes(cam, _frustum); foreach (var c in _chunks) { if (cull && !GeometryUtility.TestPlanesAABB(_frustum, c.bounds)) continue; _rp.worldBounds = c.bounds; foreach (var batch in c.batches) Graphics.RenderMeshInstanced(_rp, rockMesh, 0, batch); } } } ``` ### Why each piece is there - **Chunks.** Unity culls each call as one object using its bounds. One giant 20,000-rock call would never be culled; 64 chunks of 25 m let the camera skip most of them. We also cull chunks on the CPU against `Camera.main` (cheap: 64 AABB tests), which saves submitting calls at all. - **Pre-split arrays.** Allocating arrays every frame would churn the GC ([[Profiling and Optimization]]). Build once, reuse forever. - **`worldBounds`** per call keeps Unity's own culling and shadow-caster culling correct. - **`lightProbeUsage = BlendProbes`** lets instances pick up baked lighting from probes ([[Lighting and Baking]]); the reference documents this option. - **No colliders.** These rocks are visual. Put colliders only on the few the player can touch, or use a low-res collider mesh for the ground. > [!tip] Per-instance colour > Stock URP Lit has no per-instance colour property. For variety without a custom shader, vary scale and rotation, or use 3-4 materials (one call set each). For real per-instance data, move to the custom shader in [[4. Indirect Draws with GraphicsBuffer]]. ## 2.3 Dynamic instances If instances move (falling leaves, debris), keep a `NativeArray<RockInstance>` and update it in a Burst job ([[DOTS]] part 2), then call the `NativeArray` overload. Past ~50k moving instances, the CPU update and upload dominate; that is the signal to move simulation to a compute shader (next part). ## 2.4 Exercise 1. Profile 20k rocks with chunking off (one chunk) and on. Watch "Render Thread" time and the number of draws in the Frame Debugger. 2. Add a Shader Graph material with `instancing_options assumeuniformscaling` (Graph Settings / custom function - verify where your Shader Graph version exposes it) and raise `MaxPerBatch` to 1023. Measure whether it matters. ## Sources - https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Graphics.RenderMeshInstanced.html - https://docs.unity3d.com/6000.5/Documentation/ScriptReference/RenderParams.html - https://docs.unity3d.com/6000.5/Documentation/ScriptReference/GeometryUtility.CalculateFrustumPlanes.html - https://docs.unity3d.com/6000.5/Documentation/ScriptReference/GeometryUtility.TestPlanesAABB.html