Меня еще спросили, можно ли насквозь "прокопать" ландшафт. И мой ответ - да! Немного видео по фиче изменения ландшафта. И можно не только "копать", но и "возводить" 💃
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥13👍6❤2👎1
Генерация. Воксели
"Весь мир должен генерироваться" - моя первая мысль, когда я только задумывалась об игре)
И прежде чем генерировать всякие красивости и бегающих за игроком лисичек, надо было сгенерировать ландшафт. Самое первое решение, которое у меня получилось, было очень и очень простым: генерируем простенький шум (Perlin например), где значение шума - оффсет для вершин. Дальше двигаем вершины, например в Compute Shaders, и получаем ландшафт. Такой метод используется в Valheim. Выглядит хорошо, если увеличить плотность сетки мешей, работает очень быстро, легко распараллелить. Но есть и один очень большой минус - ландшафт не может иметь сдвиги по горизонтали, а значит никаких пещер, нависающих скал и прочих деталей, которых мне так хотелось.
Поняв, что решение в лоб мне не подходит, я начала искать другие методы генерации. И вскоре пришла к идее вокселей. Самые популярные примеры воксельных миров - Minecraft и No Man's Sky (а сейчас еще и Enshrouded).
Что такое воксель? Его можно описать как куб пространства с каким-либо значением. Прямой сосед вокселю - пиксель, который живет в 2D мире, тогда как воксель - в 3D мире. В моей задаче каждый воксель хранит значение с плавающей запятой со знаком, то есть обычный float. Это значение означает дистанцию до поверхности, где отрицательное значение - мы под поверхностью, а положительное - над ней. Значение 0 же является самой поверхностью. Такое поле дистанций имеет свое название - Signed Distance Field (SDF). Хотя во многих источниках это значение называется плотностью (density), но мне лично помогло осознать алгоритм именно через дистанцию.
Также воксель еще хранит значение градиента SDF. Что такое градиент? Это вектор изменения значений (дистанций) в каждом вокселе. Чтобы его вычислить нам необходимо знать соседние значения SDF (это уже затык для параллелизации в будущем). Но в общем случае вычисление выглядит вот так:
На основе градиента вычисляются как позиции вершин, так и их нормали, но об этом расскажу в следующих постах. Также и про генерацию шума расскажу чуть позже)
"Весь мир должен генерироваться" - моя первая мысль, когда я только задумывалась об игре)
И прежде чем генерировать всякие красивости и бегающих за игроком лисичек, надо было сгенерировать ландшафт. Самое первое решение, которое у меня получилось, было очень и очень простым: генерируем простенький шум (Perlin например), где значение шума - оффсет для вершин. Дальше двигаем вершины, например в Compute Shaders, и получаем ландшафт. Такой метод используется в Valheim. Выглядит хорошо, если увеличить плотность сетки мешей, работает очень быстро, легко распараллелить. Но есть и один очень большой минус - ландшафт не может иметь сдвиги по горизонтали, а значит никаких пещер, нависающих скал и прочих деталей, которых мне так хотелось.
Поняв, что решение в лоб мне не подходит, я начала искать другие методы генерации. И вскоре пришла к идее вокселей. Самые популярные примеры воксельных миров - Minecraft и No Man's Sky (а сейчас еще и Enshrouded).
Что такое воксель? Его можно описать как куб пространства с каким-либо значением. Прямой сосед вокселю - пиксель, который живет в 2D мире, тогда как воксель - в 3D мире. В моей задаче каждый воксель хранит значение с плавающей запятой со знаком, то есть обычный float. Это значение означает дистанцию до поверхности, где отрицательное значение - мы под поверхностью, а положительное - над ней. Значение 0 же является самой поверхностью. Такое поле дистанций имеет свое название - Signed Distance Field (SDF). Хотя во многих источниках это значение называется плотностью (density), но мне лично помогло осознать алгоритм именно через дистанцию.
Также воксель еще хранит значение градиента SDF. Что такое градиент? Это вектор изменения значений (дистанций) в каждом вокселе. Чтобы его вычислить нам необходимо знать соседние значения SDF (это уже затык для параллелизации в будущем). Но в общем случае вычисление выглядит вот так:
public float3 ComputeGradient(float x, float y, float z, int seed)
{
var x1 = ComputeDensity(x + 1, y, z, seed);
var x2 = ComputeDensity(x - 1, y, z, seed);
var y1 = ComputeDensity(x, y + 1, z, seed);
var y2 = ComputeDensity(x, y - 1, z, seed);
var z1 = ComputeDensity(x, y, z + 1, seed);
var z2 = ComputeDensity(x, y, z - 1, seed);
return math.normalize(new float3(x1 - x2, y1 - y2, z1 - z2));
}
На основе градиента вычисляются как позиции вершин, так и их нормали, но об этом расскажу в следующих постах. Также и про генерацию шума расскажу чуть позже)
🔥12🤝4👍2👎1
Генерация. Шум
Продолжаю рассказывать про генерацию, и прежде чем перейти к генерации геометрии, наверное стоит рассказать про шум. Тем более тут есть что показать интересного)
Для воксельных миров подойдет любой непрерывный шум. Это может быть всеми известный шум Перлина, или какой-нибудь 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.
Продолжаю рассказывать про генерацию, и прежде чем перейти к генерации геометрии, наверное стоит рассказать про шум. Тем более тут есть что показать интересного)
Для воксельных миров подойдет любой непрерывный шум. Это может быть всеми известный шум Перлина, или какой-нибудь 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 все равно создается копия, она просто не используется и пока ее никак не убрать😔
Еще можно использовать RenderTexture с флагом TextureDimension.Tex2DArray, но RT не поддерживает сжатие, поэтому это для каких-то исключений.
На скриншоте голубое - то, что было, с синхронизацией с GPU, а оранжевое - без нее. Как видно, разница огромная!
Вот вчера и сегодня оптимизировала генерацию текстурных данных для чанков. Была проблема, что текстурные массивы (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👍5❤2🎉2👎1
Вот еще один нюанс работы с Unity. Что-то их сегодня прям много 🥔
В Assembly Definition можно указывать требуемые версии пакетов, и даже define для этого, что удобно. Но не верьте Unity, которая пишет x >= version! Потому что это совсем не так! Define определится только, если x > version.
Вроде бы мелочь, а я потратила достаточно времени, пытаясь понять, где же ошиблась☺️
В 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 для чанка. Видно, что самих вокселей стало намного меньше.
После того как я сгенерировала воксели, я обнаружила, что количество вокселей, которые близки к поверхности, на самом деле очень мало. И большая часть вычислений на самом деле идет в пустоту. Ведь нам не нужны воксели, которые под ландшафтом, мы их все равно не увидим. Нам не нужны также воксели, которые в воздухе, мы их фактически тоже не видим.
Решение этой проблемы на самом деле достаточно простое: отсекаем все ненужное пространство и используем только те воксели, которые очень близки к поверхности.
С отсечением пространства хорошо справляются различные иерархические структуры данных. Я взяла для этого Octree, где корень - это чанк, а листья - воксели. А вот как определить, есть ли поверхность? Надо ли делить узел? Тут я пришла к набору эвристик:
- узел уже является вокселем? Тогда не разбиваем, это лист.
- в узле угловые воксели близки к поверхности (значение близко к 0)? Разбиваем узел.
- в узле угловые воксели и те, которые лежат на гранях, меняют свой знак относительно центрального? Значит тут есть поверхность, разбиваем.
- вычисляем ошибку через квадратичную функцию, она больше порога? Значит возможно тут есть поверхность, разбиваем.
С помощью octree получилось сильно оптимизировать вычисления. Отсекается от 70% до 90% пространства, и кол-во вокселей на чанк сильно уменьшается. Жаль, не смогла найти замеры с того времени, но прирост производительности можно примерно прикинуть.
На скриншоте пример результата octree для чанка. Видно, что самих вокселей стало намного меньше.
🔥16👍5❤2👎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. Пока это выглядит довольно криво, но зато работает и достаточно шустро). Да и от дырок я полностью не избавилась пока что)
Скриншот только такой нашла с тех времен, но можно увидеть саму сетку😶
После генерации вокселей настало время генерации мешей. Для этого можно взять алгоритм Marching Cubes, но он имеет несколько минусов, с которыми я не смогла согласиться:
- неоднозначность сетки в некоторых случаях (в том числе и "дырки" из-за этого)
- нельзя создать острые края
- есть проблемы в сшивании разных LOD
Поэтому я взяла алгоритм Dual Contouring, который основан на Marching Cubes, но решает все перечисленные выше проблемы. Но для этого алгоритма нужно генерировать больше информации: я уже писала в посте про воксели про градиент, вот он и нужен для Dual Contouring.
Вершины, в отличие от Marchig Cubes, размещаются не на гранях кубов, а внутри куба (чаще всего просто средняя позиция между угловыми вокселями в зависимости от их значения).
Дальше, эти вершины "адаптируется" по градиенту с помощью QEF (вот тут есть объяснение, там много математики, поэтому боюсь не объясню на пальцах). Остается только собрать получившиеся вершины в треугольники и получаем сетку.
Основные проблемы были тут распараллелить алгоритм для Jobs. Пока это выглядит довольно криво, но зато работает и достаточно шустро). Да и от дырок я полностью не избавилась пока что)
Скриншот только такой нашла с тех времен, но можно увидеть саму сетку
Please open Telegram to view this post
VIEW IN TELEGRAM
❤9✍5👍3🔥2
В предыдущем посте я рассказала про меши, но может встать вопрос, а как работать с мешами в Jobs? Ведь UnityEngine.Mesh - это managed тип, а мы хотим в Jobs, так еще и под Burst!
Для таких задач Unity добавила т.н. Extended Mesh API: они расширили API для Mesh и добавили поддержку Unity.Collections.
Вот небольшой пример как работать с мешами в Jobs:
Vertex - это кастомная структура вершины.
Для таких задач 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🔥2☃1👀1
Сегодня видимо будет рекорд по постам в день) Но решая проблему с детерминированностью инстансинга я наткнулась на одной интересное свойство SDF и градиента SDF. Может кому-нибудь окажется полезным или хотя бы интересным.
Так вот, у меня проблема, что при изменении ландшафта (например когда копаем), ранее поставленные объекты пропадали. А пропадали они потому, что угол наклона поверхности мог измениться так, что мы там и не хотим больше инстансить. Например, странно видеть дерево на отвесной скале, правда же?
Но в исходном варианте то мы уже заинстансили, и при копании нам объект нужно просто опустить. А он взял и пропал :( Решение - использовать исходные нормали для проверки, но как их получить, если генерируем по текущему состоянию?
Оказывается, градиент в SDF показывает нам динамику не только в текущей точке пространства. И зная значение изменения (что мы знаем из набора операций над ландшафтом), можно вычислить исходную нормаль в совершенно другой точке пространства. Магия😧
Первое видео - текущие нормали, второе - исходные, третье - дельта.
*В комментариях еще видео)
Так вот, у меня проблема, что при изменении ландшафта (например когда копаем), ранее поставленные объекты пропадали. А пропадали они потому, что угол наклона поверхности мог измениться так, что мы там и не хотим больше инстансить. Например, странно видеть дерево на отвесной скале, правда же?
Но в исходном варианте то мы уже заинстансили, и при копании нам объект нужно просто опустить. А он взял и пропал :( Решение - использовать исходные нормали для проверки, но как их получить, если генерируем по текущему состоянию?
Оказывается, градиент в SDF показывает нам динамику не только в текущей точке пространства. И зная значение изменения (что мы знаем из набора операций над ландшафтом), можно вычислить исходную нормаль в совершенно другой точке пространства. Магия
Первое видео - текущие нормали, второе - исходные, третье - дельта.
*В комментариях еще видео)
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
🤯8❤4🔥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. Игрок находится в центре.
В прошлый раз я остановилась на мешах. Если генерировать видимый мир (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 "уничтожают" информацию, делая чанки более "размытыми" и менее детализированными. На это у меня есть идея, как увеличить детализированность, но пробовать воплотить в жизнь буду чуть позже)
Можно также заметить, как LOD "уничтожают" информацию, делая чанки более "размытыми" и менее детализированными. На это у меня есть идея, как увеличить детализированность, но пробовать воплотить в жизнь буду чуть позже)
🔥14
Сегодня расскажу немного об архитектурных моментах в генерации. Я хоть и использую ECS, но только для геймплейной части. Я не сторонница использовать один инструмент для решения всех задач, всегда стараюсь найти более оптимальное решение (хоть и не всегда получается).
Поэтому в генерации у меня ООП подход со своими плюсами и минусами. Почему именно он? Потому что мне нужна иерархия для модулей генерации. Вся логика генерации разбита на множество небольших кусочков, которые связаны между собой контрактами. Контракты тут - generic параметры - так что появляется иерархия через композицию.
Как это выглядит? Например модуль для вокселей:
Где
Исходное API конечно выглядит немного пугающе, но это плата за возможность компоновать модули как необходимо, причем не только из кода, но и из редактора.
В каждом модуле надо реализовать абстрактный метод OnExecute, в нем содержится вся логика генерации.
Причем на выходе надо указать не только сгенерируемые данные, но и результат генерации - успешно или нет. Если не успешно, то все сабмодули у этого модуля выполнены не будут. Таким образом можно отсекать куски генерации per chunk по различным условиям.
Все модули собираются в т.н. шаблон генерации, который выполняется на N потоках (в дополнение к Jobs) per chunk. На скриншотах редактор этого шаблона, который сделан на UI Toolkit.
Поэтому в генерации у меня ООП подход со своими плюсами и минусами. Почему именно он? Потому что мне нужна иерархия для модулей генерации. Вся логика генерации разбита на множество небольших кусочков, которые связаны между собой контрактами. Контракты тут - 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 для работы с патчами, потому что я сама забываю о том, как работать с тем, что написала🫥
Создание патча выглядит просто:
Также и применение:
Где на выход отдается
Все эти аспекты связаны между собой, так что я не беспокоюсь о том, что для чанка у меня не будет кэша например. Если чанк раннее генерировался, то его кэш я получу и смогу решить, что делать дальше. Единственный аспект, который вынесен наружу - это аспект патчей.
Патчем я называю любой запрос на генерацию/перегенерацию мира. Он состоит из списка чанков, которые необходимо сгенерировать, и списка чанков, которые необходимо выгрузить (очистить кэш и удалить состояние). Патч может быть как добавляющим новые чанки к существующим, так и требующим очистку кэша и полную перегенерацию указанных чанков.
Например при движении игрока мы явно хотим добавлять новые чанки, не затрагивая существующие. А вот при копании мы уже хотим полностью перегенерировать чанк, так как у него появляются структурные изменения.
Я старалась сделать понятное 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
Кстати, пока писала предыдущий пост, заметила, что патч создается через
Все дело в том, что все больше и больше добавляются новые типы аллокаторов (тот же Rewind Allocator например), которые не представлены в enum. И используя enum мы просто теряем их поддержку. Я вот тоже использую кастомный аллокатор на базе smmalloc.
Единственный, не очень приятный, момент:
Создание:
Dispose:
И Dispose в Jobs использовать не получится! По этой причине Unity использует
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