Давайте продолжим работу над нашей веб-игрой и добавим бесконечную плоскость с помощью небольшого трюка, основанного на шейдерах!

⬅️ Урок №3: Моделирование нашего космического корабля| TOC |Урок №5: Создание препятствий и бонусов ➡️

«В предыдущем эпизоде ​​мы улучшили архитектуру нашего проекта, и экземпляр игры полностью обрабатывал инициализацию 3D-сцены. Кроме того, мы создали наш маленький космический корабль, используя геометрию буфера Three.js.

Сегодня мы собираемся добавить сетку в нашу сцену. Эта сетка позволит нам «почувствовать» движение — потому что пока этот большой черный экран не дает нам никакого указания на текущую скорость нашего корабля… и мы хотим, чтобы эта сетка была бесконечной, потому что мы создаем бесконечный бегун! ;)

Это руководство доступно как в формате видео, так и в текстовом формате — см. ниже :)

«Бесконечный» 3D-объект?

Итак, мы хотим, чтобы наша сетка была бесконечной… но проблема в том, что, конечно, мы не можем создавать «бесконечные» объекты в 3D! 3D-сетки определяются заданным набором вершин и граней, и вы не можете просто сказать компьютеру «продолжать добавлять больше».

Это означает, что сделать объект бесконечным на самом деле означает использовать различные уловки, чтобы заставить зрителя поверить, что объект больше, чем он есть на самом деле.

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

Таким образом, это будет визуально эквивалентно движению нашего корабля в этой плоскости, но:

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

Потому что в основном все будет стоять на месте, и только анимация будет создавать иллюзию движения!

Быстрая очистка кода

Давайте вернемся к нашему сценарию game.js и, прежде чем что-либо делать, разделим метод _initializeScene() на подфункции, потому что он становится немного длинным.

Я просто создам методы _createShip() и _createGrid(), которые оба принимают сцену в качестве параметра, перенесу код из функции _initializeScene() в _createShip() и вызову две функции, передав сцену:

Это облегчит переход по нашему коду и лучше описывает логику инициализации нашей сцены :)

Добавляем нашу сетку с шейдером

Хорошо, сейчас! Как мы собираемся создать эту бесконечную сетку с помощью шейдера?

Для этого я адаптировал шейдер, который нашел в этом ответе StackOverflow. Это дает мне следующий код для функции _createShip() — будьте осторожны, в исходный код внесены некоторые изменения, чтобы он соответствовал нашей текущей настройке:

Пфиу! Я знаю, что это немного долго, но не волнуйтесь — мы собираемся погрузиться в этот фрагмент кода и посмотреть, как он работает, шаг за шагом.

Создание сетки

Идея этого скрипта заключается в том, что сначала мы создаем простую сетку с Three.js GridHelper object. Этот конструктор имеет несколько параметров, таких как размер плоскости сетки, количество подразделений и цвет второстепенных и основных делений. Затем мы применяем к нему определенный материал, который использует очень специфический набор свойств, написанный в пользовательском шейдере — мы поговорим об этом через секунду:

Добавление пользовательских данных с атрибутами буфера

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

Здесь мы используем небольшую утилиту, которая есть в Three.js, метод setAttribute(), который позволяет вам определять пользовательские данные в вершинах вашей сетки, чтобы иметь дополнительную информацию. Это способ легкого хранения любых данных, к которым вы хотите получить доступ в своей вершине впоследствии, потому что по умолчанию Three.js создает некоторые базовые данные для каждой вершины, но, конечно, они могут не содержать всей информации, необходимой для вашего конкретного алгоритма. Итак, таким образом вы можете подготовить больше входной информации для кода:

В нашем случае мы используем эту функцию setAttribute(), чтобы сообщить Three.js, что некоторые определенные вершины в нашей сетке имеют небольшой «подвижный» флажок. Это позволит различить горизонтальные и вертикальные линии, так что наша анимация будет воздействовать только на горизонтальную линию сетки, и мы заполним ее так, как будто движемся в одном направлении.

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

Создание материала из пользовательского шейдера

Теперь, когда мы обсудили простые части, давайте погрузимся в шейдер!

Что такое шейдеры?

Шейдеры — очень сложная тема. Я сам погрузился в этот мир после того, как обнаружил канал Фрейи Холмер на Youtube, где у нее есть прямой курс из трех частей по шейдерам для разработчиков игр. Если вы новичок в шейдерах и хотите учиться, я определенно рекомендую вам взглянуть.

Однако, если вы хотите получить более быстрое введение в шейдеры, я подробно рассказал о них во вступительной статье моей последней серии компьютерной графики: Shader Journey! Так что обязательно прочитайте это, если вы не знакомы с общими концепциями шейдеров, такими как: свойства, вершинный шейдер, фрагментный шейдер, интерполяторы… ;)

Здесь я просто скопирую схему из той статьи, которая резюмирует весь процесс перехода из 3D-мира в 2D-пространство экрана при рендеринге 3D-объекта с помощью шейдера:

Вы начинаете с данных для каждой вершины (как автоматически сгенерированных, так и определяемых пользователем данных) в трехмерном мире. Для каждой из этих вершин вы вычисляете новый набор данных в своем вершинном шейдере. Эти вычисленные данные затем передаются интерполяторам; эта промежуточная структура данных используется GPU для вычисления всех «отсутствующих» значений для всех пикселей между вашими вершинами во время этапа интерполяции. Это просто линейное смешивание значений двух ближайших вершин в каждом направлении. Это дает вам некоторые данные для каждого фрагмента (~ для каждого пикселя) для использования в вашем фрагментном шейдере. Эта функция берет эту информацию и вычисляет, какой цвет выводить на экран для каждого пикселя изображения, предоставляя вам окончательный вариант рендеринга.

Определение наших свойств

Свойства также иногда называют «униформой»; это пользовательские входные параметры, которые используются в вашем шейдере во время вычислений (но они не для каждой вершины, а являются глобальными для всего шейдера). В нашем случае у нас есть 3 пользовательских входных параметра:

  • speedZ: скорость нашего корабля по оси Z, поэтому автоматическая скорость полета
  • gridLimits: размер нашей базовой сетки.
  • time: текущее время сцены

Обратите внимание, что вы должны определить их как объект с ключом value, чтобы код шейдера Three.js мог получить информацию в переменной с совпадающим именем.

Определение нашего вершинного шейдера

Вот часть кода, определяющая наш вершинный шейдер:

Сначала он получает наши различные входные данные (те, которые мы определили в униформах), а также некоторую информацию, относящуюся к вершине, над которой он работает, например, наш настраиваемый «подвижный» флаг или цвет вершины. Помните, что некоторые из этих данных для каждой вершины неявно передаются Three.js (например, цвет вершины). Затем мы можем использовать его напрямую или передать во фрагментный шейдер, сохранив в переменных, например vColor здесь. Эта переменная извлекается позже во фрагментном шейдере.

Затем основная процедура вершинного шейдера применяет смещение к вершинам сетки в зависимости от текущего времени и скорости, что создает анимацию. Мы используем пределы сетки, чтобы переносить смещение, когда оно достигает границы, и таким образом создается ощущение, что мы находимся в бесконечной движущейся сетке!

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

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

Определение нашего фрагментного шейдера

Наконец, наш фрагментный шейдер просто принимает интерполированную информацию о вершинах и записывает на экране соответствующий цвет пикселя. По сути, он использует интерполированную переменную vColor, которую мы передали из вершинного шейдера.

Мы используем векторы с 4 компонентами, потому что у нас есть 3 обычных красного, зеленого и синего цветовых канала, плюс альфа (для прозрачности), который установить на 1.

Обратите внимание, что мы также передаем параметр vertexColors, чтобы шейдер предоставил нам доступ к переменной vColor.

Включение этой сетки в нашу сцену

Среди всего этого нового мы находим более общие вещи, такие как новая переменная экземпляра, this.speedZ и gridLimit, которые передаются нашему шейдеру; или scene.add() в конце, чтобы создать экземпляр сетки в сцене Three.js:

Если вы сохраните это, вы увидите, что теперь в вашей сцене есть бесконечная сетка… но она не движется!

Анимация сетки!

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

Чтобы исправить это, давайте создадим маленькую переменную this.time в нашем классе Game с начальным значением, равным нулю — я сделаю это в нашей функции _createGrid(), потому что она в основном связана с этим объектом, но вы также можете поместить ее в конструктор, если хотите. предпочитать:

И затем, чтобы обновить эту временную переменную, нам придется использовать объект THREE.Clock(). Этот инструмент позволяет нам отслеживать время и дает нам дельту времени между двумя вызовами нашего метода update(). Таким образом, мы сможем соответствующим образом обновить нашу переменную this.time:

Давайте сделаем это обновление прямо сейчас: мы перейдем к функции update() и добавим новую строку для изменения текущего значения this.time:

Это просто увеличивает нашу временную переменную на небольшое количество времени, дельту, которая вычисляется между последним временем выполнения этой строки и текущим временем.

Теперь все, что нам нужно сделать, это обновить параметр внутри нашего шейдера, используя это новое значение. Мы сделаем это в нашем методе _updateGrid():

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

Поскольку сама сетка не движется, она будет продолжаться вечно, просто циклически повторяя анимацию.

Вывод

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

Это позволяет нам значительно сэкономить на вычислительных мощностях, потому что шейдеры очень эффективны, и это позволяет не просить Three.js постоянно пересчитывать множество позиций :)

Однако в следующий раз мы будем перемещать некоторые объекты: мы поработаем над созданием препятствий и бонусов на сетке и посмотрим, как их сделать двигайтесь последовательно с анимацией сетки, чтобы соответствовать иллюзии фальшивого движения…

⬅️ Урок №3: Моделирование нашего космического корабля| TOC |Урок №5: Создание препятствий и бонусов ➡️

Если вам понравилась эта статья, вы можете найти другие записи в блоге о технологиях, искусственном интеллекте и программировании на мой сайт :)