Infinity World (Дневники разработчицы)
442 subscribers
52 photos
24 videos
37 links
Канал-дневник разработчицы на Unity, рассказываю о всяком интересном и не очень, что встречается на пути разработки.

Тех стэк:
- Unity
- DOTS
Download Telegram
В предыдущем посте я рассказала про меши, но может встать вопрос, а как работать с мешами в Jobs? Ведь UnityEngine.Mesh - это managed тип, а мы хотим в Jobs, так еще и под Burst!

Для таких задач Unity добавила т.н. Extended Mesh API: они расширили API для Mesh и добавили поддержку Unity.Collections.

Вот небольшой пример как работать с мешами в Jobs:
// meshes - массив или список UnityEngine.Mesh
var meshDataArray = Mesh.AllocateWritableMeshData(meshes);
for (var i = 0; i < meshes.Length; i++)
{
var meshData = meshDataArray[i];
meshData.SetVertexBufferParams(vertexCount, Vertex.Layout);
meshData.SetIndexBufferParams(indexCount, IndexFormat.UInt32);

var vertexArray = meshData.GetVertexData<Vertex>();
var indexArray = meshData.GetIndexData<int>();
// какая-то логика
}
Mesh.ApplyAndDisposeWritableMeshData(meshDataArray, meshes, MeshUpdateFlags.DontRecalculateBounds | MeshUpdateFlags.DontResetBoneBounds);


Vertex - это кастомная структура вершины.
[StructLayout(LayoutKind.Explicit)]
[DebuggerDisplay("Position = {Position}, Normal = {Normal}")]
public readonly struct Vertex : IEquatable<Vertex>
{
public static readonly VertexAttributeDescriptor[] Layout =
{
new(VertexAttribute.Position, VertexAttributeFormat.Float32, 3),
new(VertexAttribute.Normal, VertexAttributeFormat.Float32, 3)
};

[FieldOffset(0)]
public readonly float3 Position;
[FieldOffset(12)]
public readonly float3 Normal;

public Vertex(float3 position, float3 normal)
{
Position = position;
Normal = normal;
}
}
🔥6👏3😍3👍2
А прямо сейчас работаю над детерминированным инстансингом объектов. Пока есть проблема с этой детерминированностью, но думаю, скоро покажу результаты)👏
Please open Telegram to view this post
VIEW IN TELEGRAM
9🔥21👀1
Сегодня видимо будет рекорд по постам в день) Но решая проблему с детерминированностью инстансинга я наткнулась на одной интересное свойство SDF и градиента SDF. Может кому-нибудь окажется полезным или хотя бы интересным.

Так вот, у меня проблема, что при изменении ландшафта (например когда копаем), ранее поставленные объекты пропадали. А пропадали они потому, что угол наклона поверхности мог измениться так, что мы там и не хотим больше инстансить. Например, странно видеть дерево на отвесной скале, правда же?

Но в исходном варианте то мы уже заинстансили, и при копании нам объект нужно просто опустить. А он взял и пропал :( Решение - использовать исходные нормали для проверки, но как их получить, если генерируем по текущему состоянию?

Оказывается, градиент в SDF показывает нам динамику не только в текущей точке пространства. И зная значение изменения (что мы знаем из набора операций над ландшафтом), можно вычислить исходную нормаль в совершенно другой точке пространства. Магия 😧

Первое видео - текущие нормали, второе - исходные, третье - дельта.

*В комментариях еще видео)
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
🤯84🔥1
Генерация. LOD и чанки

В прошлый раз я остановилась на мешах. Если генерировать видимый мир (4х4х4 км) сразу весь, то мы получим 82 426 462 208 вокселей! Звучит очень тяжело, а еще сохранение накатывать на этот мир, а еще с сервером синхронизировать, совсем беда.

Поэтому первым делом надо разбить все пространство на чанки - кубики с размером 16х16х16 вокселей. Каждый чанк обрабатывается отдельно и параллельно. Данные также стараюсь сохранять относительно чанков и локальных координат, так легко понять что к чему относится. Но и в таком виде, мы не уменьшаем количество вокселей, а количество чанков равно 16 777 216.

Чтобы решить эту проблему я решила обратиться к LOD (Level of Details). И даже не ради уменьшения нагрузки на GPU при отрисовке (хотя этот эффект есть), а ради уменьшения кол-ва данных, которые надо обработать.

Все чанки я сгруппировала по LOD: вокруг игрока - уровень 0, чуть дальше - 1, потом - 2 и т.д. Каждый чанк все также имеет 16х16х16 вокселей, но их размер увеличивается! Поэтому чанк с LOD 1 в 8 (2^3) раз больше, чем с LOD 0! А значит мне надо меньшее количество чанков и вокселей, чтобы покрыть большее расстояние.

Чтобы "сшить" эти чанки, мне пришлось увеличить число вокселей до 17х17х17 в каждом чанке, чтобы они перекрывали друг друга. Также как и с вокселями, тут применяется Octree, чтобы отсечь те чанки, которые "пустые". Особенно помогает при перестроении мира при движении игрока.

На скриншоте показаны чанки с разным LOD. Игрок находится в центре.
🔥7🤯3❤‍🔥1🤗1
This media is not supported in your browser
VIEW IN TELEGRAM
И вот еще видео, на котором видно как меняются LOD чанков в рантайме, когда игрок перемещается.

Можно также заметить, как LOD "уничтожают" информацию, делая чанки более "размытыми" и менее детализированными. На это у меня есть идея, как увеличить детализированность, но пробовать воплотить в жизнь буду чуть позже)
🔥14
Сегодня расскажу немного об архитектурных моментах в генерации. Я хоть и использую ECS, но только для геймплейной части. Я не сторонница использовать один инструмент для решения всех задач, всегда стараюсь найти более оптимальное решение (хоть и не всегда получается).

Поэтому в генерации у меня ООП подход со своими плюсами и минусами. Почему именно он? Потому что мне нужна иерархия для модулей генерации. Вся логика генерации разбита на множество небольших кусочков, которые связаны между собой контрактами. Контракты тут - generic параметры - так что появляется иерархия через композицию.

Как это выглядит? Например модуль для вокселей:
public sealed class VoxelsBuildModule : BitmaskCachedChunkGenerationModule<VoxelData, IVoxelSubModule, IVoxelDataProcessor>
{
// some code here
}

Где VoxelData - выходные данные, IVoxelSubModule - ограничение для сабмодулей, которые могут быть у этого модуля (они получат на вход VoxelData), ну а IVoxelDataProcessor - это абстракция для процессора данных после генерации.

Исходное API конечно выглядит немного пугающе, но это плата за возможность компоновать модули как необходимо, причем не только из кода, но и из редактора.

В каждом модуле надо реализовать абстрактный метод OnExecute, в нем содержится вся логика генерации.
protected override ResultData<VoxelData> OnExecute(in Chunk chunk, in BorderMask mask, ref CachedValue<VoxelData> cachedValue, ref PerThreadData perThreadData)
{
}

Причем на выходе надо указать не только сгенерируемые данные, но и результат генерации - успешно или нет. Если не успешно, то все сабмодули у этого модуля выполнены не будут. Таким образом можно отсекать куски генерации per chunk по различным условиям.

Все модули собираются в т.н. шаблон генерации, который выполняется на N потоках (в дополнение к Jobs) per chunk. На скриншотах редактор этого шаблона, который сделан на UI Toolkit.
10🤩3🤝3👍1
Еще немножечко про архитектуру ☺️ Я уже в общих чертах описала модули генерации и шаблон генерации, которые используются уже в самом генераторе. А если быть конкретнее - в мире генерации, который объединяет множество аспектов генерации. Это и состояние чанков, и кэширование, и сама генерация и т.д.

Все эти аспекты связаны между собой, так что я не беспокоюсь о том, что для чанка у меня не будет кэша например. Если чанк раннее генерировался, то его кэш я получу и смогу решить, что делать дальше. Единственный аспект, который вынесен наружу - это аспект патчей.

Патчем я называю любой запрос на генерацию/перегенерацию мира. Он состоит из списка чанков, которые необходимо сгенерировать, и списка чанков, которые необходимо выгрузить (очистить кэш и удалить состояние). Патч может быть как добавляющим новые чанки к существующим, так и требующим очистку кэша и полную перегенерацию указанных чанков.

Например при движении игрока мы явно хотим добавлять новые чанки, не затрагивая существующие. А вот при копании мы уже хотим полностью перегенерировать чанк, так как у него появляются структурные изменения.

Я старалась сделать понятное API для работы с патчами, потому что я сама забываю о том, как работать с тем, что написала 🫥
Создание патча выглядит просто:
public static unsafe WorldPatch Create(byte sourceId, NativeArray<Chunk> chunksToBuild, NativeArray<BorderMask> borderBitmasks, NativeArray<Chunk> chunksToRelease, Allocator allocator)
{
}

Также и применение:
public static PatchHandle ApplyPatch(this GenerationWorld world, ref WorldPatch patch, PatchOptions options = PatchOptions.Default)
{
}

Где на выход отдается PatchHandle, через который можно проверить, применился ли патч, сколько осталось чанков (полезно для экрана загрузки) и т.п.
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥2🥰2
Кстати, пока писала предыдущий пост, заметила, что патч создается через Allocator! Allocator - это enum с перечислением нескольких аллокаторов, но не сами аллокаторы. Есть рекомендация использовать AllocatorManager.AllocatorHandle, и создавать все коллекции и выделять память через него.

Все дело в том, что все больше и больше добавляются новые типы аллокаторов (тот же Rewind Allocator например), которые не представлены в enum. И используя enum мы просто теряем их поддержку. Я вот тоже использую кастомный аллокатор на базе smmalloc.

Единственный, не очень приятный, момент: NativeArray<T> по умолчанию не поддерживает AllocatorManager.AllocatorHandle! Для его создания необходимо использовать API CollectionHelper.
Создание:
var array = CollectionHelper.CreateNativeArray<int>(1000, /*какой-то аллокатор*/);

Dispose:
CollectionHelper.DisposeNativeArray(array, /*какой-то аллокатор*/);

И Dispose в Jobs использовать не получится! По этой причине Unity использует NativeList<T> или UnsafeList<T>, который легко сконвертировать во временный NativeArray<T>.
👍3
Сегодня также закончила первую версию генератора деталей 💃 Под деталями имею в виду всякие мелкие объекты без коллайдеров: камушки, палочки, веточки и все такое)

Так как эти объекты зависят от поверхности ландшафта, то генерирую на основе данных о треугольниках. И тут важно перевести позиции вершин из world space в voxel space, тогда результаты всегда будут одинаковы для конкретного вокселя, даже если поверхность изменилась. Единственный момент был - нормали, но как я уже писала, нормали можно восстановить.

Рисуются детали с помощью BRG, который отлично подходит для данной задачи. BatchRendererGroup (BRG) - это API для ручного батчинга и создания команд отрисовки. Попробую кратенько рассказать.

Регистрируем меш и материал, создаем буфер, в котором будут храниться данные для шейдера (например матрицы трансформации) и получаем BatchID.

Дальше необходимо для каждого BatchID и соответствующего ему буфера данных провести culling - собрать индексы только тех инстансов, которые видны через камеру.

В конце, для каждого BatchID, если для него есть видимые инстансы, необходимо создать команды отрисовки и отправить их на выполнение.

В ходе исследований BRG у меня появился инструмент, который я использую в этом проекте. Он простенький и его нельзя назвать универсальным, но если кому-то будет интересно, то может стать неплохой стартовой точкой.

На одном скриншоте показан редактор деталей для каждого воксель материала, а на втором - Frame Debugger.
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥54
После деталей приступила к траве! Генерация травы очень похожа на генерацию деталей, но есть и отличия, так что придется хорошенько поработать 💃
Please open Telegram to view this post
VIEW IN TELEGRAM
👍51
Media is too big
VIEW IN TELEGRAM
Первая версия травы! 🙌 Выглядит кривовато, буду еще дорабатывать)

За счет уже готового процесса добавления новых модулей генерации, это заняло всего один день. Правда много т.н. бойлерплейта, хмм, есть о чем подумать 😶
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥121
Чуть ранее я немного писала про архитектуру генератора и упомянула, но не рассказала, т.н. процессоры данных. Что это такое? Попробую объяснить.

Генератор и модули генерации отвечают только за генерацию данных в каком-либо формате. Генератор ландшафта не генерирует Mesh, он генерирует только набор вершин и описание треугольников. Генератор деталей или травы не генерирует сами детали или траву, он генерирует только матрицы трансформаций. То есть генератор - это речь только про самый минимум данных.

А как тогда получать ландшафт? Камушки? Травку и остальные объекты окружения? Да еще и с материалами!

Вот тут и вступают в игру эти процессоры данных, которые умеют переводить данные из формата от генератора в формат, который нужен для представления. Например, для ландшафта процессинг занимает несколько шагов:
- конвертирование данных о треугольниках в UnityEngine.Mesh через Extended Mesh API;
- генерация материала для каждого чанка и всех данных которые необходимы для этого материала (текстурные массивы, различные буферы и т.п.);
- отправка данных отображения (меш, материал, настройки рендеринга и т.п.) в различные системы из мира ECS, которые уже знают как из этого собрать сущность.

Процессор создать достаточно просто: наследуемся от базового класса, указываем какие данные мы хотим обрабатывать и описываем логику обработки.
public class SurfaceDataProcessor : BaseDataProcessor<SurfaceData>, ISurfaceDataProcessor
{
protected sealed override void OnDataAdded(in Chunk chunk, ref SurfaceData data){}
protected sealed override void OnDataRemoved(in Chunk chunk){}
}

Потом указываем в шаблоне генерации (на скриншоте) нужный процессор.
👍8👎1
Разработка игр это часто не просто использование одного инструмента. В моем случае приходится залезать и в С++, чтобы дописывать то, чего не хватает 🧂

Все думаю, как бы перевести это на Unity.Mathematics (и на C# соответственно), и под Burst. А еще кодоген! Чтобы количество вызовов уменьшить и убрать абстракции. Пока это только мечта...😢
Please open Telegram to view this post
VIEW IN TELEGRAM
👍9😢3🌚1
Все еще вожусь с травой 👏
Поняла, что не хватает LOD для деталей и травы. Надо будет запланировать добавить поддержку в LOD для BRG.

Также очень не хватает смешивания травы и ландшафта, слишком резкая граница как по мне. Но это визуал, над которым я еще совсем не работала)
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥10
Media is too big
VIEW IN TELEGRAM
Трава кстати уже шевелится 💃 Я взяла ассеты из сэмплов HDRP, чтобы сейчас немного укориться ☺️
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥103👎1
В комментариях я обещала рассказать про основные структуры данных, о которых я уже упоминала 💃

Воксель - просто информация о пространстве: density и gradient + информация по визуализации (индексы материалов).
public readonly struct Voxel : IEquatable<Voxel>
{
public readonly float3 Gradient;
public readonly half Density;
...

public readonly FixedVoxelType4 VoxelTypes;
...
}

Позицию в вокселе я не храню, так как доступ к ним и так через позицию, и хранятся они в отношении позиция вокселя -> воксель. Но это не HashMap! А обычный массив с вычислением индекса из позиции: math.dot(coordinate, new int3(1, dimension.x, dimension.x * dimension.y)); (когда математику вдруг можно применить и в таких случаях).

Чанк - тут еще меньше информации: позиция и lod!
public readonly struct Chunk : IEquatable<Chunk>
{
public readonly int3 Position;
public readonly ChunkLod Lod;
}

Вся идея состоит в том, чтобы из любой точки кода можно было сказать: "я хочу сгенерировать пространство для вот этой позиции с таким-то уровнем детальности". Ну и еще отсюда вытекает множество плюсов в виде хранения данных в формате AOS (кэш-able), легковесность, расширяемость (сам чанк не знает, что в нем хранится, он выступает только в качестве ключа).

Octree - фактически, дерево у меня представлено в виде HashMap, где ключ - просто число без знака, а значение - нода с какой-либо информацией.
public readonly struct LocationCode : IEquatable<LocationCode>
{
public readonly uint Value;
}

Ключ - просто набор бит (причем используются первые 30 бит). Зная, что каждая нода разбивается на 8 нод, на каждый уровень можно выделить по 3 бита (2^3 = 8). Максимальная глубина соответственно равна 10, чего мне хватает с запасом. Чтобы получить значение ребенка: (source.Value << 3) | childIndex, где childIndex - значение от 0 до 7. Чтобы получить значение родителя: code >> 3.

Немного побитовых операций, а уже не надо в ноде хранить ссылки на другие ноды ☺️ Я после этого начала использовать побитовые операции везде где надо и не надо 🤭
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥169👏3🤯2
Когда немного переборщила с травой 🤭

Пофиксила заполнение буфера, теперь рисуется вся трава, причем довольно быстро (30+ фпс есть). Но если глянуть в Frame Debugger, то можно увидеть сколько на самом деле рисуется инстансов - 26473! А вершины и индексы определяются десятками миллионов!

Как рисовать такие объемы? Например с помощью GPU Instancing. Но еще лучше - сгруппировать все меши и материалы и использовать Batch Renderer Group! Я использую свой инструмент - BRG Container.

Следующий шаг - добавить поддержку LOD. А то и выглядит так себе при движении, и треугольников нууу слишком уж много 😔
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥16😱421