26.10.2026 16 мин чтения Просмотров:0

GPU instancing в Unity: тысячи объектов без GameObject

Как рисовать десятки тысяч одинаковых мешей без GameObject: Graphics.RenderMeshInstanced для простых случаев, RenderMeshIndirect со своим шейдером и GPU-буфером, compute shader и отсечение по камере.

GPU instancing в Unity: тысячи объектов без GameObject

Трава на поле, астероидный пояс, толпа из десяти тысяч зомби, снаряды в 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 нужен тому, что живёт в сцене и взаимодействует с ней. Всё, что существует только для того, чтобы быть нарисованным, дешевле держать массивом и отдавать видеокарте одним вызовом.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также