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

Тех стэк:
- Unity
- DOTS
Download Telegram
Генерация. Шум

Продолжаю рассказывать про генерацию, и прежде чем перейти к генерации геометрии, наверное стоит рассказать про шум. Тем более тут есть что показать интересного)

Для воксельных миров подойдет любой непрерывный шум. Это может быть всеми известный шум Перлина, или какой-нибудь Simplex. Хотя подойдет даже просто обычная синусоида, главное выбрать несколько итераций с разной периодичностью и сложить вместе. Но, я человек-визуал, и мне сложно через числа что-то собрать адекватное 🤪, поэтому с самого начала я ориентировалась на визуальные инструменты и граф шума.

Под графом шума я подразумеваю именно набор нод и связей между собой. Чтобы упростить себе работу, я взяла уже готовое решение - FastNoise2. Это С++ библиотека, которая содержит множество API для генерации разнообразного шума, причем с поддержкой SIMD (хотя тут не все так просто). И самое главное - предоставляет граф из коробки!
Не все так просто с SIMD у меня по той причине, что я пишу на C# (привет P/Invoke) и использую Jobs + Burst. Также, я вычисляю шум для каждого вокселя отдельно, и не могу сократить кол-во вызовов P/Invoke, которые очень дорогие по сравнению с прямым вызовом метода. Ну и теряю векторизацию и все оптимизации Burst. Вот и получается, что SIMD вроде и есть, но очень условно.
Решение тут одно: переход на Burst математику и кодогенерацию по графу. Но это пока только в планах 🥹

Вокруг библиотеки FastNoise2 у меня написан обычный C# враппер и редактор для Unity, чтобы можно было визуально связывать ноды и сразу видеть результат. Editor часть (на скриншоте) реализована с помощью Unity.GraphView, который до сих пор в экспериментальном статусе, и фреймворка NodeGraphProcessor. Очень жду Graph Tools Foundation, который должен быть с Unity 6 (предположительно) и который должен быть намного быстрее, чем GraphView.

Сам граф в Runtime выглядит просто как поинтер (IntPtr) на C++ часть, и я хотя бы не пробегаюсь по графу в C# части. Использование unmanaged типов позволяет мне их упаковать в Unity.Collections и использовать в Jobs.
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥12👍5👎1🤔1
Кстати, может стоит сюда писать и о всяких мелочах?) Например, различных нюансах Unity, с которыми я сталкиваюсь в ходе работы над проектом.

Вот вчера и сегодня оптимизировала генерацию текстурных данных для чанков. Была проблема, что текстурные массивы (Texture2DArray), которые у меня генерируются в runtime на лету, создавались слишком долго. Причина оказалась в том, что даже в случае, если массив используется только на GPU стороне, на CPU также создается его копия, и (!) после создания копируется на GPU. Это приводило к огромным задержкам (до 1 секунды 🔥).
Я нашла "экспериментальное" API - флаги TextureCreationFlags, с помощью которых можно указать необходимое поведение. Но флаг TextureCreationFlags.DontInitializePixels не убирал синхронизацию с GPU, а вот флаги TextureCreationFlags.DontInitializePixels | TextureCreationFlags.DontUploadUponCreate уже привели к нужному поведению. Но правда на CPU все равно создается копия, она просто не используется и пока ее никак не убрать 😔
var textureArray = new Texture2DArray(width, height, 16,GraphicsFormat.RGBA_DXT1_SRGB, TextureCreationFlags.DontInitializePixels | TextureCreationFlags.DontUploadUponCreate | TextureCreationFlags.MipChain, mipCount)
{
filterMode = FilterMode.Bilinear,
wrapMode = TextureWrapMode.Repeat
};


Еще можно использовать RenderTexture с флагом TextureDimension.Tex2DArray, но RT не поддерживает сжатие, поэтому это для каких-то исключений.

На скриншоте голубое - то, что было, с синхронизацией с GPU, а оранжевое - без нее. Как видно, разница огромная!
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥18👍52🎉2👎1
Вот еще один нюанс работы с Unity. Что-то их сегодня прям много 🥔
В Assembly Definition можно указывать требуемые версии пакетов, и даже define для этого, что удобно. Но не верьте Unity, которая пишет x >= version! Потому что это совсем не так! Define определится только, если x > version.
Вроде бы мелочь, а я потратила достаточно времени, пытаясь понять, где же ошиблась ☺️
Please open Telegram to view this post
VIEW IN TELEGRAM
🤯8🤔1
Генерация. Octree.

После того как я сгенерировала воксели, я обнаружила, что количество вокселей, которые близки к поверхности, на самом деле очень мало. И большая часть вычислений на самом деле идет в пустоту. Ведь нам не нужны воксели, которые под ландшафтом, мы их все равно не увидим. Нам не нужны также воксели, которые в воздухе, мы их фактически тоже не видим.
Решение этой проблемы на самом деле достаточно простое: отсекаем все ненужное пространство и используем только те воксели, которые очень близки к поверхности.

С отсечением пространства хорошо справляются различные иерархические структуры данных. Я взяла для этого Octree, где корень - это чанк, а листья - воксели. А вот как определить, есть ли поверхность? Надо ли делить узел? Тут я пришла к набору эвристик:
- узел уже является вокселем? Тогда не разбиваем, это лист.
- в узле угловые воксели близки к поверхности (значение близко к 0)? Разбиваем узел.
- в узле угловые воксели и те, которые лежат на гранях, меняют свой знак относительно центрального? Значит тут есть поверхность, разбиваем.
- вычисляем ошибку через квадратичную функцию, она больше порога? Значит возможно тут есть поверхность, разбиваем.

С помощью octree получилось сильно оптимизировать вычисления. Отсекается от 70% до 90% пространства, и кол-во вокселей на чанк сильно уменьшается. Жаль, не смогла найти замеры с того времени, но прирост производительности можно примерно прикинуть.

На скриншоте пример результата octree для чанка. Видно, что самих вокселей стало намного меньше.
🔥16👍52👎1
Кстати, если визуализировать воксели без всяких оптимизаций, то есть по 4913 вокселей (17х17х17) на чанк, то это будет выглядеть вот так)

Красиво, но сколько вычислять 😱
Please open Telegram to view this post
VIEW IN TELEGRAM
7😍3🔥2😱1
Генерация. Меши

После генерации вокселей настало время генерации мешей. Для этого можно взять алгоритм Marching Cubes, но он имеет несколько минусов, с которыми я не смогла согласиться:
- неоднозначность сетки в некоторых случаях (в том числе и "дырки" из-за этого)
- нельзя создать острые края
- есть проблемы в сшивании разных LOD

Поэтому я взяла алгоритм Dual Contouring, который основан на Marching Cubes, но решает все перечисленные выше проблемы. Но для этого алгоритма нужно генерировать больше информации: я уже писала в посте про воксели про градиент, вот он и нужен для Dual Contouring.

Вершины, в отличие от Marchig Cubes, размещаются не на гранях кубов, а внутри куба (чаще всего просто средняя позиция между угловыми вокселями в зависимости от их значения).

Дальше, эти вершины "адаптируется" по градиенту с помощью QEF (вот тут есть объяснение, там много математики, поэтому боюсь не объясню на пальцах). Остается только собрать получившиеся вершины в треугольники и получаем сетку.

Основные проблемы были тут распараллелить алгоритм для Jobs. Пока это выглядит довольно криво, но зато работает и достаточно шустро). Да и от дырок я полностью не избавилась пока что)

Скриншот только такой нашла с тех времен, но можно увидеть саму сетку 😶
Please open Telegram to view this post
VIEW IN TELEGRAM
95👍3🔥2
В предыдущем посте я рассказала про меши, но может встать вопрос, а как работать с мешами в 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