Трава на поле, астероидный пояс, толпа из десяти тысяч зомби, снаряды в bullet hell — всё это упирается в одно: каждый GameObject с MeshRenderer стоит памяти, времени на Transform-иерархию и места в списке рендера. В этой статье разберём, как рисовать тысячи одинаковых мешей вообще без GameObject: через Graphics.RenderMeshInstanced для простых случаев и Graphics.RenderMeshIndirect со своим шейдером и GPU-буфером для десятков и сотен тысяч объектов. Будет рабочий код, ограничения, на которых все спотыкаются, и простое отсечение по камере.
Зачем вообще уходить от GameObject
У обычного объекта сцены есть скрытая цена. Даже пустой GameObject — это нативный объект, C#-обёртка, Transform с матрицами и флагами изменений, а MeshRenderer добавляет запись в системе культинга и батчинга. Пока объектов сотни, это незаметно. Когда их десятки тысяч, начинают тормозить уже не шейдеры, а сам CPU: обход трансформов, культинг, подготовка draw call.
Если объектам не нужны коллайдеры, скрипты, аниматоры и всё остальное, что даёт GameObject, их можно описать просто массивом данных — позиция, поворот, масштаб, может быть цвет — и отдать видеокарте одной командой. Это и есть GPU instancing: видеокарта рисует один и тот же меш N раз за один вызов, а отличия между копиями берёт из данных на экземпляр.
Типичные кандидаты:
- растительность и мелкий декор: трава, камни, грибы, мусор;
- снаряды, частицы-«объекты», гильзы, обломки;
- массовка: птицы, рыбы, толпа на дальнем плане;
- визуализация данных и сеток: воксели, клетки, тайлы;
- отладочная отрисовка тысяч точек или векторов.
Логику при этом вы держите сами — в обычных массивах, NativeArray, Job System или compute shader. Unity только рисует.
Три уровня API
В Unity 2022.1 появилось семейство функций Graphics.RenderMesh*, которое заменяет старые DrawMeshInstanced и DrawMeshInstancedIndirect. Все они принимают структуру RenderParams с общими настройками отрисовки. Для нашей задачи важны три функции:
- Graphics.RenderMesh — один меш, одна матрица. Удобно для пары объектов, для тысяч не годится.
- Graphics.RenderMeshInstanced — один меш, массив матриц, GPU instancing. Работает со стандартными материалами, если у них включён инстансинг.
- Graphics.RenderMeshIndirect — аргументы отрисовки лежат в GPU-буфере, данные экземпляров вы тоже кладёте в буферы и читаете в своём шейдере. Нужна поддержка compute shaders на платформе.
Все функции рисуют только в текущем кадре. Это не «создать объект», а «нарисовать сейчас», поэтому вызывать их нужно каждый кадр, обычно из Update или LateUpdate.
RenderMeshInstanced: быстрый старт
Начнём с минимального примера: поле из кубов, позиции которых считаются один раз.
using UnityEngine;
public class InstancedField : MonoBehaviour
{
[SerializeField] private Mesh mesh;
[SerializeField] private Material material;
[SerializeField] private int countX = 30;
[SerializeField] private int countZ = 30;
[SerializeField] private float spacing = 1.5f;
private Matrix4x4[] matrices;
private RenderParams renderParams;
private void Start()
{
if (!SystemInfo.supportsInstancing)
{
Debug.LogError("GPU instancing не поддерживается на этой платформе");
enabled = false;
return;
}
material.enableInstancing = true;
renderParams = new RenderParams(material);
matrices = new Matrix4x4[countX * countZ];
int i = 0;
for (int x = 0; x < countX; x++)
for (int z = 0; z < countZ; z++)
{
Vector3 position = new Vector3(x * spacing, 0f, z * spacing);
matrices[i++] = Matrix4x4.TRS(position, Quaternion.identity, Vector3.one);
}
}
private void Update()
{
Graphics.RenderMeshInstanced(renderParams, mesh, 0, matrices);
}
}
Повесьте компонент на пустой объект, назначьте меш куба и любой материал на стандартном шейдере URP Lit или Built-in Standard — и получите 900 кубов. Кажется, что всё готово. Но если увеличить сетку до 100×100, Unity бросит исключение. Пора поговорить об ограничениях.
Ограничение на количество экземпляров
Данные экземпляров при RenderMeshInstanced уходят в константный буфер, а его размер ограничен. Документация Unity формулирует это так:
- максимум 1023 экземпляра за вызов;
- по умолчанию на каждый экземпляр передаются две матрицы —
objectToWorldиworldToObject, поэтому реальный предел для стандартного шейдера — 511; - если в шейдере есть
#pragma instancing_options assumeuniformscaling, обратная матрица не передаётся и в буфер помещается больше.
Практический вывод: разбивайте массив на пачки. Удобно, что у функции есть параметры instanceCount и startInstance, так что копировать массив не нужно:
private const int BatchSize = 511;
private void Update()
{
for (int start = 0; start < matrices.Length; start += BatchSize)
{
int count = Mathf.Min(BatchSize, matrices.Length - start);
Graphics.RenderMeshInstanced(renderParams, mesh, 0, matrices, count, start);
}
}
Десять тысяч объектов — это 20 вызовов вместо десяти тысяч. Для CPU разница огромная.
Материал обязан поддерживать инстансинг
Если у материала выключен enableInstancing, RenderMeshInstanced бросает InvalidOperationException. То же самое произойдёт, если платформа не поддерживает API или GPU instancing вообще — поэтому в примере стоит проверка SystemInfo.supportsInstancing. Включить флаг можно в коде, как выше, или галочкой в материале — результат одинаковый.
Второй момент: шейдер должен уметь инстансинг. Стандартные шейдеры URP и Built-in умеют. В своём шейдере нужны #pragma multi_compile_instancing и макросы UNITY_VERTEX_INPUT_INSTANCE_ID / UNITY_SETUP_INSTANCE_ID. Shader Graph добавляет поддержку автоматически.
Своя структура вместо Matrix4x4
Вместо массива матриц можно передать массив собственной структуры. Unity ищет в ней поля по имени и типу:
Matrix4x4 objectToWorld— обязательное;uint renderingLayerMask— необязательное, иначе берётся из RenderParams;Matrix4x4 prevObjectToWorld— необязательное, для motion vectors.
Остальные поля рендер игнорирует, и туда можно положить свои данные — скорость, время жизни, тип. Это удобно: один массив служит и для симуляции, и для отрисовки.
public struct Projectile
{
public Matrix4x4 objectToWorld;
public Vector3 velocity;
public float lifetime;
}
private Projectile[] projectiles;
private int aliveCount;
private void Update()
{
float dt = Time.deltaTime;
for (int i = 0; i < aliveCount; i++)
{
ref Projectile p = ref projectiles[i];
p.lifetime -= dt;
if (p.lifetime <= 0f)
{
// Удаление swap-back: последний живой встаёт на место мёртвого.
projectiles[i] = projectiles[--aliveCount];
i--;
continue;
}
Vector3 position = p.objectToWorld.GetPosition() + p.velocity * dt;
p.objectToWorld = Matrix4x4.TRS(position, Quaternion.LookRotation(p.velocity), Vector3.one * 0.2f);
}
for (int start = 0; start < aliveCount; start += BatchSize)
{
int count = Mathf.Min(BatchSize, aliveCount - start);
Graphics.RenderMeshInstanced(renderParams, mesh, 0, projectiles, count, start);
}
}
Обратите внимание на удаление: живые снаряды всегда лежат в начале массива подряд, и мы рисуем только первые aliveCount. Никаких аллокаций, никаких списков с RemoveAt.
Границы и отсечение
По умолчанию Unity считает общий bounding box для всех экземпляров вызова и отсекает их как единое целое. Это значит две вещи. Во-первых, если хоть один экземпляр пачки виден, рисуется вся пачка. Во-вторых, подсчёт границ стоит времени. Если вы знаете, где лежат объекты, задайте RenderParams.worldBounds явно.
Отсюда полезная идея: группируйте экземпляры в пачки по пространству, а не по порядку в массиве. Если поле травы разбито на чанки 16×16 метров и каждый чанк — отдельный вызов со своими границами, то чанки за спиной камеры отбрасываются целиком.
Тени, слои и свет
Всё, что у MeshRenderer настраивается в инспекторе, здесь задаётся в RenderParams:
renderParams = new RenderParams(material)
{
shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.On,
receiveShadows = true,
layer = gameObject.layer,
lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.BlendProbes,
worldBounds = new Bounds(transform.position, new Vector3(200f, 20f, 200f))
};
Поле camera ограничивает отрисовку одной камерой; по умолчанию (null) объекты видят все камеры. Отбрасывание теней удваивает работу: экземпляры рисуются ещё и в shadow map. Для мелкой травы тени часто выключают.
Когда RenderMeshInstanced становится мало
При 100 тысячах объектов у RenderMeshInstanced появляются две проблемы. Первая — каждый кадр массив матриц копируется с CPU на GPU пачками по 511 штук, это 200 вызовов и мегабайты данных. Вторая — если объекты и так двигаются на GPU (например, в compute shader), гонять их обратно на CPU ради отрисовки бессмысленно.
Решение — indirect rendering. Данные экземпляров лежат в GraphicsBuffer на видеокарте постоянно, а количество экземпляров и прочие аргументы отрисовки — в отдельном буфере аргументов. CPU делает один вызов, лимита в 511 нет.
RenderMeshIndirect: буфер аргументов
Аргументы одной indexed-отрисовки описывает структура GraphicsBuffer.IndirectDrawIndexedArgs:
indexCountPerInstance— сколько индексов у меша (для сабмеша 0 —mesh.GetIndexCount(0));instanceCount— сколько экземпляров нарисовать;startIndex,baseVertexIndex— смещения в индексном и вершинном буфере, для сабмешей;startInstance— номер первого экземпляра.
Unity прямо советует использовать эту структуру, а не набивать буфер «сырыми» int: раскладка может отличаться на разных платформах. Буфер создаётся с целью GraphicsBuffer.Target.IndirectArguments и шагом IndirectDrawIndexedArgs.size.
Скрипт отрисовки
Сделаем рой: 100 000 экземпляров, у каждого позиция, масштаб и цвет. Данные лежат в структурированном буфере, шейдер читает их по номеру экземпляра.
using System.Runtime.InteropServices;
using UnityEngine;
public class IndirectSwarm : MonoBehaviour
{
[StructLayout(LayoutKind.Sequential)]
private struct InstanceData
{
public Vector3 position;
public float scale;
public Vector4 color;
}
[SerializeField] private Mesh mesh;
[SerializeField] private Material material;
[SerializeField] private int instanceCount = 100_000;
[SerializeField] private float radius = 100f;
private GraphicsBuffer instanceBuffer;
private GraphicsBuffer argsBuffer;
private GraphicsBuffer.IndirectDrawIndexedArgs[] args;
private RenderParams renderParams;
private static readonly int InstancesId = Shader.PropertyToID("_Instances");
private void OnEnable()
{
var data = new InstanceData[instanceCount];
var random = new System.Random(42);
for (int i = 0; i < instanceCount; i++)
{
Vector3 p = new Vector3(
(float)random.NextDouble() * 2f - 1f,
(float)random.NextDouble() * 2f - 1f,
(float)random.NextDouble() * 2f - 1f) * radius;
data[i] = new InstanceData
{
position = p,
scale = 0.3f + (float)random.NextDouble() * 0.7f,
color = Color.HSVToRGB((float)random.NextDouble(), 0.6f, 1f)
};
}
instanceBuffer = new GraphicsBuffer(
GraphicsBuffer.Target.Structured,
instanceCount,
Marshal.SizeOf<InstanceData>());
instanceBuffer.SetData(data);
argsBuffer = new GraphicsBuffer(
GraphicsBuffer.Target.IndirectArguments,
1,
GraphicsBuffer.IndirectDrawIndexedArgs.size);
args = new GraphicsBuffer.IndirectDrawIndexedArgs[1];
args[0].indexCountPerInstance = mesh.GetIndexCount(0);
args[0].instanceCount = (uint)instanceCount;
args[0].startIndex = mesh.GetIndexStart(0);
args[0].baseVertexIndex = mesh.GetBaseVertex(0);
argsBuffer.SetData(args);
renderParams = new RenderParams(material)
{
worldBounds = new Bounds(transform.position, Vector3.one * radius * 2f + Vector3.one * 2f),
matProps = new MaterialPropertyBlock()
};
renderParams.matProps.SetBuffer(InstancesId, instanceBuffer);
}
private void OnDisable()
{
instanceBuffer?.Release();
instanceBuffer = null;
argsBuffer?.Release();
argsBuffer = null;
}
private void Update()
{
Graphics.RenderMeshIndirect(renderParams, mesh, argsBuffer);
}
}
Несколько деталей, которые легко пропустить:
- Буферы нужно освобождать вручную. GraphicsBuffer не собирается GC; без
Release()вOnDisableвы получите предупреждение об утечке и потерю видеопамяти при каждом перезапуске Play Mode или перекомпиляции. - Размер шага буфера должен совпадать с размером структуры в шейдере.
Marshal.SizeOfздесь даёт 32 байта:float3 + float + float4. В HLSL структура объявляется в том же порядке. - worldBounds обязательны по смыслу. Unity не знает, где на GPU окажутся экземпляры, и отсекает весь вызов по заданному боксу. Слишком маленький бокс — объекты пропадают, когда центр уходит из кадра; огромный — культинг не работает вовсе.
- Структура
InstanceDataспециально выровнена:Vector3иfloatвместе дают 16 байт, затемVector4. Так удобнее и для GPU, и для отладки.
Шейдер
RenderMeshIndirect не умеет подставлять матрицы в стандартный шейдер — данные экземпляров читает ваш код. Ниже минимальный шейдер на HLSL с простым ламбертовским освещением от направленного вектора. Проход без тега LightMode URP рисует как unlit-проход, Built-in — как обычный, так что пример работает в обоих пайплайнах.
Shader "Custom/IndirectSwarm"
{
Properties
{
_LightDir ("Light Direction", Vector) = (0.4, 0.8, 0.3, 0)
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 4.5
#include "UnityCG.cginc"
#define UNITY_INDIRECT_DRAW_ARGS IndirectDrawIndexedArgs
#include "UnityIndirect.cginc"
struct InstanceData
{
float3 position;
float scale;
float4 color;
};
StructuredBuffer<InstanceData> _Instances;
float4 _LightDir;
struct v2f
{
float4 pos : SV_POSITION;
float3 normal : TEXCOORD0;
float4 color : COLOR0;
};
v2f vert(appdata_base v, uint svInstanceID : SV_InstanceID)
{
InitIndirectDrawArgs(0);
uint instanceID = GetIndirectInstanceID(svInstanceID);
InstanceData data = _Instances[instanceID];
float3 worldPos = v.vertex.xyz * data.scale + data.position;
v2f o;
o.pos = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0));
o.normal = v.normal;
o.color = data.color;
return o;
}
float4 frag(v2f i) : SV_Target
{
float ndl = saturate(dot(normalize(i.normal), normalize(_LightDir.xyz)));
return float4(i.color.rgb * (0.25 + 0.75 * ndl), 1.0);
}
ENDCG
}
}
}
Ключевое здесь — пара строк с UnityIndirect.cginc. Макрос UNITY_INDIRECT_DRAW_ARGS говорит, что используется indexed-вариант аргументов, InitIndirectDrawArgs(0) инициализирует доступ к ним, а GetIndirectInstanceID(svInstanceID) превращает системный SV_InstanceID в номер экземпляра с учётом startInstance. Есть и GetCommandID(0) — номер команды, если в буфере аргументов их несколько, и GetIndirectInstanceCount().
Поворотов в примере нет: объекты только масштабируются и сдвигаются. Если нужен поворот, храните в буфере кватернион или полную матрицу float4x4 и умножайте на неё вершину и нормаль. Матрица — 64 байта на экземпляр, кватернион — 16, при сотне тысяч объектов разница уже ощутима.
Несколько мешей одним вызовом
Буфер аргументов может содержать несколько команд. Например, LOD-уровни одного объекта, если они лежат в одном меше разными сабмешами: каждая команда получает свои startIndex, indexCountPerInstance и baseVertexIndex, а в шейдере GetCommandID(0) подскажет, к какой команде относится вершина. Количество команд передаётся параметром commandCount:
Graphics.RenderMeshIndirect(renderParams, mesh, argsBuffer, commandCount: 3);
Двигаем экземпляры на GPU
Раз данные уже живут в видеопамяти, логичный шаг — обновлять их там же compute shader-ом. CPU не трогает ни одного экземпляра, только запускает ядро и вызывает отрисовку.
#pragma kernel Orbit
struct InstanceData
{
float3 position;
float scale;
float4 color;
};
RWStructuredBuffer<InstanceData> _Instances;
uint _Count;
float _DeltaTime;
[numthreads(64, 1, 1)]
void Orbit(uint3 id : SV_DispatchThreadID)
{
if (id.x >= _Count) return;
InstanceData data = _Instances[id.x];
float angle = _DeltaTime * (0.2 + 0.3 * frac(id.x * 0.618));
float s = sin(angle);
float c = cos(angle);
float3 p = data.position;
data.position = float3(p.x * c - p.z * s, p.y, p.x * s + p.z * c);
_Instances[id.x] = data;
}
Со стороны C# добавляется немного кода:
[SerializeField] private ComputeShader orbitShader;
private int kernel;
private static readonly int CountId = Shader.PropertyToID("_Count");
private static readonly int DeltaTimeId = Shader.PropertyToID("_DeltaTime");
// в OnEnable, после создания instanceBuffer:
kernel = orbitShader.FindKernel("Orbit");
orbitShader.SetBuffer(kernel, InstancesId, instanceBuffer);
orbitShader.SetInt(CountId, instanceCount);
private void Update()
{
orbitShader.SetFloat(DeltaTimeId, Time.deltaTime);
int groups = Mathf.CeilToInt(instanceCount / 64f);
orbitShader.Dispatch(kernel, groups, 1, 1);
Graphics.RenderMeshIndirect(renderParams, mesh, argsBuffer);
}
Проверка id.x >= _Count в ядре обязательна: количество групп округляется вверх, и последние потоки последней группы выходят за пределы буфера. Запись за границу RWStructuredBuffer на одних API молча игнорируется, на других портит соседние данные.
Обратите внимание, что буфер с целью Structured один и тот же: compute shader пишет в него как в RWStructuredBuffer, вершинный шейдер читает как StructuredBuffer. Копирования нет.
Отсечение по камере на CPU
Один вызов с огромным bounding box означает, что видеокарта обрабатывает вершины всех ста тысяч экземпляров, даже если камера смотрит на десятую их часть. Самый простой способ это исправить — разбить экземпляры на пространственные чанки и проверять каждый чанк по пирамиде видимости камеры.
private readonly Plane[] frustumPlanes = new Plane[6];
private struct Chunk
{
public Bounds bounds;
public uint startInstance;
public uint instanceCount;
}
private Chunk[] chunks;
private void DrawVisibleChunks(Camera cam)
{
GeometryUtility.CalculateFrustumPlanes(cam, frustumPlanes);
int visible = 0;
for (int i = 0; i < chunks.Length; i++)
{
if (!GeometryUtility.TestPlanesAABB(frustumPlanes, chunks[i].bounds))
continue;
args[visible].indexCountPerInstance = mesh.GetIndexCount(0);
args[visible].instanceCount = chunks[i].instanceCount;
args[visible].startInstance = chunks[i].startInstance;
visible++;
}
if (visible == 0) return;
argsBuffer.SetData(args, 0, 0, visible);
Graphics.RenderMeshIndirect(renderParams, mesh, argsBuffer, visible);
}
Чтобы это работало, экземпляры в буфере должны быть отсортированы по чанкам: все объекты чанка лежат подряд, начиная с startInstance. Буфер аргументов создаётся сразу на максимальное число чанков, а заполняется только видимыми — через перегрузку SetData с количеством элементов. Шейдер менять не нужно: GetIndirectInstanceID уже учитывает startInstance каждой команды.
Следующий шаг — культинг целиком на GPU: compute shader проверяет каждый экземпляр, складывает видимые в AppendStructuredBuffer, а GraphicsBuffer.CopyCount копирует счётчик прямо в буфер аргументов, без чтения на CPU. Это мощный приём, но он требует аккуратности со смещением поля instanceCount в буфере, и для большинства проектов чанкового отсечения хватает с запасом.
Подводные камни
Объекты не видны в Scene View или пропадают
Почти всегда виноваты границы. Для RenderMeshIndirect Unity не видит реальных позиций экземпляров и отсекает вызов по worldBounds. Если объекты двигаются на GPU, бокс должен покрывать всю область, где они могут оказаться. Ещё одна причина — RenderParams.camera, ограниченный одной камерой.
Кадр с мусором вместо меша
Неверные аргументы: перепутаны startIndex и baseVertexIndex у сабмеша, или не совпадает размер структуры в C# и HLSL. Сверяйте Marshal.SizeOf с суммой полей в шейдере и помните, что bool и Vector3 в C# и HLSL упаковываются по-разному. Надёжнее хранить только float, float4, uint.
Утечки после перекомпиляции
Unity выгружает домен при перекомпиляции скриптов, и всё, что не освобождено в OnDisable, повисает до перезапуска редактора. Создавайте буферы в OnEnable, а освобождайте в OnDisable — тогда пара симметрична при любом сценарии.
Нет интерактивности
Экземпляр нельзя выделить кликом, у него нет коллайдера и не срабатывают OnTriggerEnter. Если с объектами нужна физика, держите для них отдельное представление: например, собственный пространственный хэш вместо Physics.OverlapSphere или простые проверки расстояний в той же логике, что двигает экземпляры. В редакторе можно задать RenderParams.instanceID равным GetInstanceID() компонента, чтобы клик по нарисованной геометрии выделял объект с этим скриптом.
Мобильные платформы
RenderMeshIndirect требует compute shaders: на старых Android-устройствах с OpenGL ES 3.0 их нет. Проверяйте SystemInfo.supportsComputeShaders и держите запасной путь через RenderMeshInstanced. А ещё на мобильных видеокартах узким местом чаще становится заполнение пикселей, а не количество вызовов, поэтому не стоит ждать чудес при отрисовке крупных перекрывающихся объектов.
А как же GPU Resident Drawer и ECS?
В Unity 6 появился GPU Resident Drawer: он автоматически переводит обычные MeshRenderer на инстансинг через BatchRendererGroup. Он хорош, когда объекты — полноценные GameObject, расставленные в сцене, и вам нужно, чтобы они рисовались дешевле без переписывания кода. Но GameObject при этом никуда не исчезают: память и обход трансформов остаются. Кроме того, у Resident Drawer есть требования к пайплайну (URP с Forward+ или HDRP) и к сборке шейдерных вариантов.
Entities Graphics в ECS решает задачу системно, но тянет за собой весь стек DOTS. Если в проекте его нет, ради травы и снарядов его подключать не стоит.
Ручной инстансинг остаётся лучшим выбором, когда:
- объекты существуют только как данные и порождаются кодом;
- их количество меняется каждый кадр (снаряды, частицы);
- симуляция живёт в Job System или compute shader;
- нужен полный контроль над тем, что и как попадает в кадр.
Итог: какой вариант выбрать
- До нескольких тысяч объектов, стандартный материал — RenderMeshInstanced пачками по 511 штук, данные в массиве своей структуры с полем
objectToWorld. - Десятки тысяч и больше или данные уже на GPU — RenderMeshIndirect, StructuredBuffer с данными экземпляров и свой шейдер с
UnityIndirect.cginc. - Объекты двигаются по простым правилам — compute shader пишет в тот же буфер, из которого читает вершинный шейдер.
- Большая область — пространственные чанки, отсечение через
GeometryUtility.TestPlanesAABBи по команде в буфере аргументов на каждый видимый чанк.
Главное правило одно: GameObject нужен тому, что живёт в сцене и взаимодействует с ней. Всё, что существует только для того, чтобы быть нарисованным, дешевле держать массивом и отдавать видеокарте одним вызовом.