Как они это сделали: миллионы плиток в Террарии

45

Я работал над игровым движком, похожим на Terraria , в основном как вызов, и, хотя я понял большую его часть, я не могу понять, как они справляются с миллионами интерактивных / собираемых плиток в игре есть одно время. Создание около 500 000 плиток, что составляет 1/20 от того, что возможно в Terraria , в моем движке приводит к падению частоты кадров с 60 до около 20, даже если я все еще только отображаю плитки в поле зрения. Имейте в виду, я ничего не делаю с плитками, только храню их в памяти.

Обновление : код добавлен, чтобы показать, как я делаю вещи.

Это часть класса, который обрабатывает плитки и рисует их. Я предполагаю, что виновником является часть «foreach», которая повторяет все, даже пустые индексы.

...
    public void Draw(SpriteBatch spriteBatch, GameTime gameTime)
    {
        foreach (Tile tile in this.Tiles)
        {
            if (tile != null)
            {
                if (tile.Position.X < -this.Offset.X + 32)
                    continue;
                if (tile.Position.X > -this.Offset.X + 1024 - 48)
                    continue;
                if (tile.Position.Y < -this.Offset.Y + 32)
                    continue;
                if (tile.Position.Y > -this.Offset.Y + 768 - 48)
                    continue;
                tile.Draw(spriteBatch, gameTime);
            }
        }
    }
...

Также здесь есть метод Tile.Draw, который также можно сделать с обновлением, поскольку каждый Tile использует четыре вызова метода SpriteBatch.Draw. Это часть моей системы автотайлинга, которая подразумевает рисование каждого угла в зависимости от соседних плиток. texture_ * - это прямоугольники, которые устанавливаются один раз при создании уровня, а не при каждом обновлении.

...
    public virtual void Draw(SpriteBatch spriteBatch, GameTime gameTime)
    {
        if (this.type == TileType.TileSet)
        {
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position, texture_tl, this.BlendColor);
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position + new Vector2(8, 0), texture_tr, this.BlendColor);
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position + new Vector2(0, 8), texture_bl, this.BlendColor);
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position + new Vector2(8, 8), texture_br, this.BlendColor);
        }
    }
...

Любая критика или предложения к моему коду приветствуются.

Обновление : решение добавлено.

Вот последний метод Level.Draw. Метод Level.TileAt просто проверяет введенные значения, чтобы избежать исключений OutOfRange.

...
    public void Draw(SpriteBatch spriteBatch, GameTime gameTime)
    {
        Int32 startx = (Int32)Math.Floor((-this.Offset.X - 32) / 16);
        Int32 endx = (Int32)Math.Ceiling((-this.Offset.X + 1024 + 32) / 16);
        Int32 starty = (Int32)Math.Floor((-this.Offset.Y - 32) / 16);
        Int32 endy = (Int32)Math.Ceiling((-this.Offset.Y + 768 + 32) / 16);

        for (Int32 x = startx; x < endx; x += 1)
        {
            for (Int32 y = starty; y < endy; y += 1)
            {
                Tile tile = this.TileAt(x, y);
                if (tile != null)
                    tile.Draw(spriteBatch, gameTime);

            }
        }
    }
...
Уильям Мариажер
источник
2
Вы абсолютно уверены, что воспроизводите только то, что находится в поле зрения камеры, то есть код, который определяет, что делать правильно?
Купер
5
Частота кадров падает с 60 до 20 кадров в секунду, только из-за выделенной памяти? Это очень маловероятно, должно быть что-то не так. Что это за платформа? Система подменяет виртуальную память на диск?
Майк Земдер
6
@Drackir В этом случае я бы сказал, что это неправильно, если даже есть класс плиток, должен подойти массив целых чисел подходящей длины, а когда имеется полмиллиона объектов, накладные расходы ОО - не шутка. Я предполагаю, что это возможно сделать с объектами, но какой в ​​этом смысл? Целочисленный массив очень прост в обращении.
аааааааааааа
3
Ой! Перебирая все плитки и вызывая Draw четыре раза на каждой из них. Здесь определенно возможны некоторые улучшения ...
Thedaian
11
Вам не нужны все причудливые разделы, о которых говорилось ниже, чтобы визуализировать только то, что видно. Это карта тайлов. Это уже разделено на регулярную сетку. Просто вычислите плитку в верхнем левом углу экрана и в нижнем правом углу и нарисуйте все в этом прямоугольнике.
Блецки

Ответы:

56

Вы просматриваете все 500 000 плиток при рендеринге? Если так, то это, вероятно, вызовет часть ваших проблем. Если вы перебираете полмиллиона плиток при рендеринге и полмиллиона плиток при выполнении отметок «обновление», то вы зацикливаете по миллиону плиток в каждом кадре.

Очевидно, что есть способы обойти это. Вы можете выполнять свои тики обновления во время рендеринга, тем самым экономя половину времени, затрачиваемого на циклы по всем этим плиткам. Но это связывает ваш код рендеринга и ваш код обновления вместе в одну функцию, и, как правило, это ПЛОХАЯ ИДЕЯ .

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

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

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

thedaian
источник
5
Часть региона кажется интересной, если я правильно помню, именно так и поступает майнкрафт, однако она не работает с тем, как развивается местность в Террарии, даже если вы совсем рядом с ней, например, с распространением травы, выращиванием кактусов и тому подобным. Изменить : Если, конечно, они применяют время, прошедшее с момента последнего активирования региона, а затем выполняют все изменения, которые произошли бы в этот период. Это может на самом деле работать.
Уильям Маригер
2
Minecraft определенно использует регионы (и более поздние игры Ultima сделали, и я уверен, что многие из игр Bethesda используют). Я менее уверен в том, как Terraria обрабатывает ландшафт, но они, вероятно, либо применяют прошедшее время к «новому» региону, либо хранят всю карту в памяти. Последнее потребует большого объема оперативной памяти, но большинство современных компьютеров справятся с этим. Могут быть и другие варианты, которые также не были упомянуты.
Thedaian
2
@MindWorX: Выполнение всех обновлений при перезагрузке не всегда будет работать - предположим, у вас есть целая куча бесплодной области, а затем трава. Вы уходите целую вечность и затем идете к траве. Когда блок с травой загружается, он догоняет и распространяется в этом блоке, но не распространяется на более близкие блоки, которые уже загружены. Такие вещи развиваются НАМНОГО медленнее, чем игра - проверьте только небольшое подмножество плиток за один цикл обновления, и вы сможете оживить удаленную местность, не загружая систему.
Лорен Печтел
1
В качестве альтернативы (или дополнения) «догоняющего», когда область приближается к игроку, вы могли бы вместо этого иметь отдельную симуляцию, повторяющуюся для каждой отдаленной области и обновляющую их с более медленной скоростью. Вы можете зарезервировать время для этого каждого кадра или запустить его в отдельном потоке. Например, вы можете обновить регион, в котором находится игрок, плюс 6 соседних регионов в каждом кадре, а также обновить 1 или 2 дополнительных области.
Льюис Уэйкфорд
1
Для всех, кто интересуется, Terraria хранит все свои данные тайлов в 2d массиве (максимальный размер 8400x2400). А затем использует простую математику, чтобы решить, какую часть плиток визуализировать.
FrenchyNZ
4

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

Вы должны перебирать только то, что действительно необходимо для обработки. Так что подумайте, что вам на самом деле нужно для плитки. Чтобы нарисовать меня, вам нужна только текстура, но вы не хотите перебирать реальное изображение, так как оно велико для обработки. Вы можете просто сделать int [,] или даже неподписанный байт [,] (если вы не ожидаете более 255 текстур плиток). Все, что вам нужно сделать, это перебрать эти небольшие массивы и использовать оператор switch или if для рисования текстуры.

Так что вам нужно обновить? Тип, здоровье и ущерб кажутся достаточными. Все это может быть сохранено в байтах. Так почему бы не создать такую ​​структуру для цикла обновления:

struct TileUpdate
{
public byte health;
public byte type;
public byte damage;
}

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

Если вы сохраняете вышеуказанную структуру, она занимает всего 3 байта на плитку. Так что для сохранения и памяти это идеально. Для скорости обработки не имеет значения, используете ли вы int или byte, или даже long int, если у вас 64-битная система.

Madmenyo
источник
1
Да, более поздние итерации моего движка эволюционировали, чтобы использовать это. Главным образом уменьшить потребление памяти и увеличить скорость. Типы ByRef являются медленными по сравнению с массивом, заполненным структурами ByVal. Тем не менее, это хорошо, что вы добавили это в ответ.
Уильям Маригер
1
Да, наткнулся на это, ища некоторые случайные алгоритмы. Сразу заметил, что вы используете свой собственный класс плиток в своем коде. Это очень распространенная ошибка, когда ваш новый. Приятно видеть, что вы все еще активны, так как вы опубликовали это 2 года назад.
Madmenyo
3
Вам не нужно ни то, healthни другое damage. Вы можете сохранить небольшой буфер с недавно выбранными местоположениями плиток и уроном каждого. Если выбрана новая плитка и буфер заполнен, то перед добавлением новой выкатите из нее самое старое место. Это ограничивает количество плиток, которые вы можете добывать за раз, но в любом случае есть внутреннее ограничение (примерно #players * pick_tile_size). Вы можете сохранить этот список для каждого игрока, если это облегчит задачу. Размер имеет значение для скорости; меньший размер означает больше плиток в каждой кэш-строке процессора.
Шон Мидлдич
@SeanMiddleditch Вы правы, но это не главное. Цель состояла в том, чтобы остановиться и подумать, что вам действительно нужно, чтобы нарисовать плитки на экране и сделать ваши расчеты. Так как ОП использовал целый класс, я сделал простой пример, простой проходит долгий путь в процессе обучения. Разблокируйте мою тему плотности пикселей, вы, очевидно, программист, пытаетесь взглянуть на этот вопрос глазами CG-художника. Так как этот сайт также для компьютерной графики для игр afaik.
Madmenyo
2

Есть разные методы кодирования, которые вы можете использовать.

RLE: Итак, вы начинаете с координаты (x, y), а затем подсчитываете, сколько одинаковых плиток существует бок о бок (длина) вдоль одной из осей. Пример: (1,1,10,5) будет означать, что начиная с координаты 1,1 имеется 10 плиток рядом с типом плиток 5.

Массивный массив (растровое изображение): каждый элемент массива содержит тип плитки, который находится в этой области.

РЕДАКТИРОВАТЬ: Я только что столкнулся с этим превосходным вопросом здесь: функция случайного семени для генерации карты?

Генератор шума Perlin выглядит как хороший ответ.

Сэм Топор
источник
Я не совсем уверен, что вы отвечаете на вопрос, который задают, поскольку вы говорите о кодировании (и перлин-шуме), и этот вопрос, похоже, касается проблем производительности.
Thedaian
1
Используя правильное кодирование (такое как перлин-шум), вы можете не беспокоиться о том, чтобы одновременно хранить все эти тайлы в памяти ... вместо этого вы просто просите свой кодировщик (генератор шума) сказать вам, что должно появляться в данной координате. Здесь есть баланс между циклами процессора и использованием памяти, и генератор шума может помочь с этим действием балансировки.
Сэм Топ
2
Проблема здесь в том, что если вы создаете игру, которая позволяет пользователям каким-либо образом изменять ландшафт, просто используя генератор шума для «хранения» этой информации, не будет работать. Кроме того, генерация шума довольно дорогая, как и большинство методов кодирования. Вам лучше экономить циклы ЦП для реальных игровых вещей (физика, столкновения, освещение и т. Д.). Шум Perlin отлично подходит для генерации рельефа, а кодирование - для сохранения на диск, но в этом случае есть более эффективные способы обработки памяти.
Thedaian
Однако очень хорошая идея, как и в случае с thedaian, о том, что местность взаимодействует с пользователем, что означает, что я не могу математически получить информацию. В любом случае я буду смотреть на шум перлина, чтобы создать базовый ландшафт.
Уильям Маригер
1

Вы, вероятно, должны разделить карту тайлов, как уже предлагалось. Например, со структурой Quadtree, чтобы избавиться от любой потенциальной обработки (например, даже просто циклического прохождения) ненужных (не видимых) плиток. Таким образом, вы обрабатываете только то, что может потребовать обработки, и увеличение размера набора данных (карты листов) не приводит к практическому снижению производительности. Конечно, предполагая, что дерево хорошо сбалансировано.

Я не хочу звучать скучно или что-то подобное, повторяя «старое», но при оптимизации всегда не забывайте использовать оптимизации, поддерживаемые вашей цепочкой инструментов / компилятором, вам следует немного поэкспериментировать с ними. И да, преждевременная оптимизация - корень всего зла. Доверяйте своему компилятору, он знает лучше вас в большинстве случаев, но всегда, всегдаизмерять дважды и никогда не полагаться на догадки. Речь идет не о быстрой реализации самого быстрого алгоритма, если вы не знаете, где на самом деле узкое место. Вот почему вы должны использовать профилировщик, чтобы найти самые медленные (горячие) пути кода и сосредоточиться на их устранении (или оптимизации). Низкий уровень знаний целевой архитектуры часто необходим для того, чтобы выжать все, что может предложить аппаратное обеспечение, поэтому изучите эти кэши ЦП и узнайте, что такое предсказатель ветвлений. Посмотрите, что ваш профилировщик говорит вам о попаданиях / промахах кэша / ветки. И как показывает использование некоторой формы древовидной структуры данных, лучше иметь интеллектуальные структуры данных и глупые алгоритмы, чем наоборот. Данные приходят первыми, когда речь заходит о производительности. :)

zxcdw
источник
+1 за "преждевременная оптимизация - корень всего зла" я виноват в этом :(
thedaian
16
-1 квадри? Немного перебить? Когда все плитки одинакового размера в 2D-игре, подобной этой (которая предназначена для террариумов), очень легко определить, какой диапазон плиток находится на экране, - это всего лишь крайний левый (на экране) крайняя правая плитка
BlueRaja - Дэнни Пфлюгофт
1

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

О динамическом подборе ... Может быть, дерево квадов не такая уж плохая идея. Предполагая, что плитки помещаются в листовые, а неконечные узлы являются просто пакетными сетками своих дочерних элементов, корень должен содержать все плитки, объединенные в одну сетку. Удаление одной плитки требует обновлений узлов (повторной сборки сетки) вплоть до корня. Но на каждом уровне дерева есть только 1/4-ая часть меша, что должно быть много, 4 * tree_height меш присоединяется?

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

Просто мои мысли, без кода, может быть, это чепуха.

прибытие
источник
0

@arrival прав. Проблема в коде розыгрыша. Вы строите массив 4 * 3000 + рисовать квадроциклов команды (24000+ Жеребьевка полигонных команд) для каждого кадра. Затем эти команды обрабатываются и передаются в графический процессор. Это довольно плохо.

Есть несколько решений.

  • Рендеринг больших блоков (например, размер экрана) плиток в статическую текстуру и рисование в один вызов.
  • Используйте шейдеры для рисования тайлов, не отправляя геометрию в GPU каждый кадр (т.е. сохраняйте карту тайлов в GPU).
Арк-кун
источник
0

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

С точки зрения обработки таких вещей, как растения, растущие в областях, удаленных от текущего экрана игрока, у вас могут быть таймеры, например. Эти таймеры будут перебирать, скажем, файлы, хранящие информацию о растениях, их положении и т. Д. Вам просто нужно прочитать / обновить / сохранить файлы в таймерах. Когда игрок снова достигает этих частей света, движок считывает файлы в обычном режиме и выводит на экран новые данные о заводе.

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

Джейсон Кумбс
источник
-2

Я думал о том, как справиться с таким количеством миллиардов блоков, и единственное, что приходит мне в голову, это шаблон проектирования Flyweight. Если вы не знаете, я настоятельно рекомендую прочитать об этом: это может помочь, когда речь идет о экономии памяти и обработки: http://en.wikipedia.org/wiki/Flyweight_pattern

Тиаго Фроссард
источник
3
-1 Этот ответ слишком общий, чтобы быть полезным, и ссылка, которую вы предоставляете, напрямую не решает эту конкретную проблему. Шаблон Flyweight является одним из многих, многих способов эффективного управления большими наборами данных; Не могли бы вы уточнить, как бы вы применили этот шаблон в конкретном контексте хранения тайлов в игре, подобной Terraria?
postgoodism