Рендеринг и анимация сотен тысяч блоков с помощью RenderMeshPrimitives

  • PublicPublic
  • 24 Sep, 2026
Рендеринг и анимация сотен тысяч блоков с помощью RenderMeshPrimitives

Изображение можно разбить на огромный массив цветных блоков через Graphics.RenderMeshPrimitives, а затем анимировать его волнами, сборкой, отталкиванием и обвалом на играбельной частоте кадров. Подход начинается с того, что исходная текстура превращается в список данных о позиции и цвете каждого блока. Задаётся горизонтальное число блоков gridWidth, а вертикальное выводится из соотношения сторон изображения — так исходные пиксели прореживаются с равными интервалами, при этом полностью прозрачные и почти чёрные пиксели пропускаются как фон.

Каждый блок хранится в структуре PixelBlock с полями float3 position и float4 color, в точности повторяющей раскладку HLSL. В Awake метод texture.GetPixels32 читает исходное изображение, а цикл for по координатам сетки сопоставляет каждой ячейке сетки исходный пиксель, смещая позиции так, чтобы центр сетки оказался в начале координат. Байты цвета делятся на 255, превращаясь в значения 0–1. Первый наивный проход создаёт по одному GameObject на блок с общим мешем, материалом и MaterialPropertyBlock, но на примерно 100 000 блоках всё сваливается до 6 FPS, потому что каждый объект добавляет команды отрисовки и накладные расходы компонентов.

Решением становится Graphics.RenderMeshPrimitives — API, специально созданный для многократного отрисовки одного и того же меша. Данные экземпляров загружаются один раз в GraphicsBuffer (GraphicsBuffer.Target.Structured, sizeof(float) * 7 на элемент), затем SetData(blocks) переносит весь массив из CPU на GPU одним вызовом. Аргумент stride должен совпадать с размером структуры HLSL, и его нужно обновлять при изменении полей. Единственный вызов Graphics.RenderMeshPrimitives(renderParams, cubeMesh, 0, blocks.Length) рисует всё, пока шейдер достаёт позицию и цвет по SV_InstanceID. worldBounds должен быть достаточно большим, чтобы вместить все блоки: слишком маленький скрывает блоки, а слишком большой сводит на нет отсечение; буфер стоит освобождать в OnDisable, чтобы избежать утечек.

На примерно 100 000 блоках версия с GameObject выросла до 54 525 вызовов отрисовки и примерно 6 FPS, тогда как версия на RenderMeshPrimitives держалась на 42 вызовах отрисовки на всю сцену и достигала 700–865 FPS. При увеличении примерно в десять раз — до 1,04 миллиона блоков — вызовы отрисовки остались на уровне 42, а частота кадров держалась около 435 FPS; цифры взяты из окна Statistics в Unity. Поскольку смещение читается из буфера на стороне GPU, а CPU выдаёт всего одну команду отрисовки, стоимость остаётся низкой даже при росте числа блоков.

Анимация идёт по двум путям в зависимости от того, нужно ли состояние предыдущего кадра. Движение, заданное только временем, целиком выполняется в вершинном шейдере: волна добавляет sin вдоль Z, используя _WaveAmplitude, _WaveFrequency и _WaveSpeed, а эффект сборки интерполирует разбросанную позицию к исходной, управляемой значением _Progress. Направления разброса берутся из GenerateHashedRandomFloat с ключом по ID экземпляра, поэтому они детерминированы и не требуют сохранённого состояния, а стартовые пороги, сдвинутые по высоте, заставляют изображение подниматься снизу.

Для движений, переносящих скорость или позицию между кадрами, используется вычислительный шейдер. Эффект магнита хранит текущие позиции в буфере чтения-записи и отталкивает блоки рядом с курсором, затем каждый кадр лерпит позиции к их целям перед отрисовкой обновления. Гравитационный обвал каждый кадр интегрирует скорость в буфер BlockState, отскакивая при приземлении с _Restitution и засыпая, когда скорость падает ниже _SleepThreshold; _DeltaTime при этом ограничен значением 1/30 секунды, чтобы избежать взрыва интегрирования, а позиции покоя заранее вычисляются из плотности по колонкам.