В современной веб-разработке границы между классическими и веб-приложениями стираются с каждым днем. Сегодня мы можем создавать не только интерактивные сайты, но и полноценные игры прямо в браузере. Одним из инструментов, который делает это возможным, является библиотека React Three Fiber — мощный инструмент для создания 3D-графики на основе Three.js с использованием технологии React .
React Three Fiber — это оболочка над Three.js , которая использует структуру и принципы React для создания 3D-графики в Интернете. Этот стек позволяет разработчикам объединить мощь Three.js с удобством и гибкостью React , делая процесс создания приложения более интуитивным и организованным.
В основе React Three Fiber лежит идея о том, что все, что вы создаете в сцене, является компонентом React . Это позволяет разработчикам применять знакомые шаблоны и методологии.
Одним из главных преимуществ React Three Fiber является простота интеграции с экосистемой React . Любые другие инструменты React по-прежнему можно легко интегрировать при использовании этой библиотеки.
За последние годы Web-GameDev претерпела серьезные изменения: от простых 2D Flash- игр до сложных 3D-проектов, сравнимых с настольными приложениями. Этот рост популярности и возможностей делает Web-GameDev областью, которую нельзя игнорировать.
Одним из главных преимуществ веб-игр является их доступность. Игрокам не нужно скачивать и устанавливать какое-либо дополнительное программное обеспечение – достаточно перейти по ссылке в своем браузере. Это упрощает распространение и продвижение игр, делая их доступными для широкой аудитории по всему миру.
Наконец, разработка веб-игр может стать для разработчиков отличным способом попробовать свои силы в разработке игр с использованием знакомых технологий. Благодаря имеющимся инструментам и библиотекам даже без опыта работы в 3D-графике можно создавать интересные и качественные проекты!
Современные браузеры прошли долгий путь, превратившись из довольно простых инструментов просмотра веб-страниц в мощные платформы для запуска сложных приложений и игр. Основные браузеры, такие как Chrome , Firefox , Edge и другие , постоянно оптимизируются и развиваются для обеспечения высокой производительности, что делает их идеальной платформой для разработки сложных приложений.
Одним из ключевых инструментов, который способствовал развитию браузерных игр, является WebGL . Этот стандарт позволил разработчикам использовать аппаратное ускорение графики, что значительно улучшило производительность 3D-игр. Вместе с другими веб-API WebGL открывает новые возможности для создания впечатляющих веб-приложений прямо в браузере.
Тем не менее, при разработке игр для браузера крайне важно учитывать различные аспекты производительности: оптимизация ресурсов, управление памятью и адаптация под разные устройства — все это ключевые моменты, которые могут повлиять на успех проекта.
Однако слова и теория – это одно, а практический опыт – совсем другое. Чтобы по-настоящему понять и оценить весь потенциал разработки веб-игр, лучший способ — погрузиться в процесс разработки. Поэтому в качестве примера успешной разработки веб-игр мы создадим собственную игру. Этот процесс позволит нам изучить ключевые аспекты разработки, столкнуться с реальными проблемами и найти их решения, а также увидеть, насколько мощной и гибкой может быть платформа для разработки веб-игр.
В серии статей мы рассмотрим, как создать шутер от первого лица, используя возможности этой библиотеки, и окунемся в захватывающий мир веб-геймдева!
Репозиторий на GitHub
Теперь давайте начнем!
Прежде всего, нам понадобится шаблон проекта React . Итак, начнем с его установки.
npm create vite@latest
Установите дополнительные пакеты npm.
npm install three @react-three/fiber @react-three/drei @react three/rapier zustand @tweenjs/tween.js
Затем удалите из нашего проекта все ненужное.
В файле main.jsx добавьте элемент div, который будет отображаться на странице в качестве области видимости. Вставьте компонент Canvas и установите поле зрения камеры. Внутри компонента Canvas поместите компонент App .
Давайте добавим стили в index.css , чтобы растянуть элементы пользовательского интерфейса на всю высоту экрана и отобразить область видимости в виде круга в центре экрана.
В компонент App мы добавляем компонент Sky , который будет отображаться в качестве фона в нашей игровой сцене в виде неба.
Давайте создадим компонент Ground и поместим его в компонент App .
В Ground создайте элемент плоской поверхности. По оси Y переместите его вниз так, чтобы эта плоскость оказалась в поле зрения камеры. А также переверните плоскость по оси X, чтобы сделать ее горизонтальной.
Несмотря на то, что в качестве цвета материала мы указали серый, плоскость кажется полностью черной.
По умолчанию в сцене нет освещения, поэтому добавим источник светаambientLight , который освещает объект со всех сторон и не имеет направленного луча. В качестве параметра задайте интенсивность свечения.
Чтобы поверхность пола не выглядела однородной, добавим текстуру. Сделайте рисунок поверхности пола в виде ячеек, повторяющихся по всей поверхности.
В папку с ресурсами добавьте изображение PNG с текстурой.
Чтобы загрузить текстуру на сцену, воспользуемся хуком useTexture из пакета @react-three/drei . А в качестве параметра для хука мы передадим импортированное в файл изображение текстуры. Установите повторение изображения по горизонтальным осям.
С помощью компонента PointerLockControls из пакета @react-three/drei зафиксируйте курсор на экране так, чтобы он не перемещался при перемещении мыши, а менял положение камеры на сцене.
Давайте внесем небольшие изменения в компонент «Земля» .
Для наглядности добавим в сцену простой куб.
<mesh position={[0, 3, -5]}> <boxGeometry /> </mesh>
Сейчас он просто висит в космосе.
Используйте компонент «Физика» из пакета @react-three/rapier, чтобы добавить «физику» в сцену. В качестве параметра настраиваем гравитационное поле, где задаем гравитационные силы по осям.
<Physics gravity={[0, -20, 0]}> <Ground /> <mesh position={[0, 3, -5]}> <boxGeometry /> </mesh> </Physics>
Однако наш куб находится внутри физической составляющей, но с ним ничего не происходит. Чтобы куб вел себя как настоящий физический объект, нам нужно обернуть его в компонент RigidBody из пакета @react-three/rapier .
После этого мы сразу увидим, что при каждой перезагрузке страницы куб падает вниз под действием силы тяжести.
Но теперь есть другая задача — необходимо сделать пол объектом, с которым куб сможет взаимодействовать, и за пределы которого он не упадет.
Давайте вернемся к компоненту Ground и добавим компонент RigidBody в качестве оболочки над поверхностью пола.
Теперь при падении куб остается на полу, как настоящий физический объект.
Давайте создадим компонент Player , который будет управлять персонажем на сцене.
Персонаж представляет собой тот же физический объект, что и добавленный куб, поэтому он должен взаимодействовать с поверхностью пола так же, как и с кубом на сцене. Вот почему мы добавляем компонент RigidBody . И сделаем персонажа в виде капсулы.
Поместите компонент Player внутри компонента Physics.
Теперь на сцене появился наш персонаж.
Управляться персонажем будет с помощью клавиш WASD , а прыгать — с помощью клавиши «Пробел» .
С помощью собственного React-хука мы реализуем логику перемещения персонажа.
Давайте создадим файлooks.js и добавим туда новую функцию usePersonControls .
Давайте определим объект в формате {"код ключа": "действие, которое необходимо выполнить"}. Затем добавьте обработчики событий для нажатия и отпускания клавиш клавиатуры. При срабатывании обработчиков мы определим текущие выполняемые действия и обновим их активное состояние. В конечном результате хук вернет объект в формате {"действие в процессе": "статус"}.
После реализации хука usePersonControls его следует использовать при управлении персонажем. В компоненте Player мы добавим отслеживание состояния движения и обновим вектор направления движения персонажа.
Мы также определим переменные, которые будут хранить состояния направлений движения.
Чтобы обновить положение персонажа, давайте воспользуемся фреймом , предоставляемым пакетом @react-three/fiber . Этот хук работает аналогично requestAnimationFrame и выполняет тело функции примерно 60 раз в секунду.
Объяснение кода:
1. const playerRef = useRef(); Создайте ссылку для объекта игрока. Эта ссылка позволит напрямую взаимодействовать с объектом игрока на сцене.
2. const {вперед, назад, влево, вправо, прыжок} = usePersonControls(); При использовании перехватчика возвращается объект с логическими значениями, указывающими, какие кнопки управления в данный момент нажаты игроком.
3. useFrame((state) => { ... }); Крючок вызывается в каждом кадре анимации. Внутри этого хука обновляются положение и линейная скорость игрока.
4. возврат if (!playerRef.current); Проверяет наличие объекта игрока. Если объект игрока отсутствует, функция остановит выполнение, чтобы избежать ошибок.
5. константная скорость = playerRef.current.linvel(); Получите текущую линейную скорость игрока.
6. frontVector.set(0, 0, назад-вперед); Установите вектор движения вперед/назад в зависимости от нажатых кнопок.
7.sideVector.set(слева-справа, 0, 0); Установите вектор движения влево/вправо.
8. направление.subVectors(frontVector,sideVector).normalize().multiplyScalar(MOVE_SPEED); Вычислите конечный вектор движения игрока, вычитая векторы движения, нормируя результат (так, чтобы длина вектора была равна 1) и умножая на константу скорости движения.
9. playerRef.current.wakeUp(); «Буждает» объект игрока, чтобы убедиться, что он реагирует на изменения. Если не использовать этот метод, через некоторое время объект «уснет» и не будет реагировать на изменение положения.
10. playerRef.current.setLinvel({ x: Direction.x, y: Velocity.y, Z: Direction.z }); Установите новую линейную скорость игрока на основе рассчитанного направления движения и сохраните текущую вертикальную скорость (чтобы не влиять на прыжки или падения).
В результате при нажатии клавиш WASD персонаж начинал перемещаться по сцене. Он также может взаимодействовать с кубом, поскольку они оба являются физическими объектами.
Для реализации прыжка воспользуемся функционалом из пакетов @dimforge/rapier3d-compat и @react-three/rapier . В этом примере давайте проверим, что персонаж находится на земле и нажата клавиша прыжка. В данном случае мы задаем направление и силу ускорения персонажа по оси Y.
Для Player мы добавим массу и вращение блока по всем осям, чтобы он не падал в разные стороны при столкновении с другими объектами на сцене.
Объяснение кода:
- константный мир = рапира.мир; Получение доступа к сцене физического движка Rapier . Он содержит все физические объекты и управляет их взаимодействием.
- const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 })); Именно здесь и происходит «рейкастинг» (raycasting). Создается луч, который начинается в текущей позиции игрока и указывает вниз по оси Y. Этот луч «прибрасывается» в сцену, чтобы определить, пересекается ли он с каким-либо объектом в сцене.
- const grounded = ray && ray.collider && Math.abs(ray.toi) <= 1.5; Условие проверяется, если игрок находится на земле:
- луч - был ли создан луч ;
- ray.collider — столкнулся ли луч с каким-либо объектом на сцене;
- Math.abs(ray.toi) — «время экспозиции» луча. Если это значение меньше или равно заданному значению, это может указывать на то, что игрок находится достаточно близко к поверхности, чтобы считаться «находящимся на земле».
Также необходимо изменить компонент Ground , чтобы алгоритм трассировки лучей для определения статуса «приземления» работал корректно, добавив физический объект, который будет взаимодействовать с другими объектами сцены.
Давайте поднимем камеру немного выше, чтобы лучше рассмотреть сцену.
Код раздела
Чтобы переместить камеру, мы получим текущую позицию игрока и изменим положение камеры каждый раз при обновлении кадра. А чтобы персонаж двигался точно по той траектории, куда направлена камера, нам нужно добавить applyEuler .
Объяснение кода:
Метод applyEuler применяет поворот к вектору на основе заданных углов Эйлера. В этом случае вращение камеры применяется к вектору направления . Это используется для согласования движения относительно ориентации камеры, чтобы игрок двигался в направлении вращения камеры.
Давайте немного подкорректируем размер Player и сделаем его выше относительно куба, увеличив размер CapsuleCollider и исправив логику «прыжка».
Код раздела
Чтобы сцена не казалась совершенно пустой, добавим генерацию куба. В json-файле перечислите координаты каждого из кубов и затем отобразите их на сцене. Для этого создадим файл Cubes.json , в котором будем перечислять массив координат.
[ [0, 0, -7], [2, 0, -7], [4, 0, -7], [6, 0, -7], [8, 0, -7], [10, 0, -7] ]
В файле Cube.jsx создайте компонент Cubes , который будет генерировать кубы в цикле. А компонент Cube будет непосредственно сгенерированным объектом.
import {RigidBody} from "@react-three/rapier"; import cubes from "./cubes.json"; export const Cubes = () => { return cubes.map((coords, index) => <Cube key={index} position={coords} />); } const Cube = (props) => { return ( <RigidBody {...props}> <mesh castShadow receiveShadow> <meshStandardMaterial color="white" /> <boxGeometry /> </mesh> </RigidBody> ); }
Добавим созданный компонент Cubes в компонент App , удалив предыдущий одиночный куб.
Теперь добавим на сцену 3D-модель. Добавим модель оружия для персонажа. Начнем с поиска 3D модели. Например, возьмем вот этот .
Скачайте модель в формате GLTF и распакуйте архив в корень проекта.
Чтобы получить нужный нам формат для импорта модели в сцену, нам нужно будет установить дополнительный пакет gltf-pipeline .
npm i -D gltf-pipeline
С помощью пакета gltf-pipeline переконвертируем модель из формата GLTF в формат GLB , так как в этом формате все данные модели помещаются в один файл. В качестве выходного каталога для сгенерированного файла мы указываем общую папку.
gltf-pipeline -i weapon/scene.gltf -o public/weapon.glb
Затем нам нужно сгенерировать компонент реагирования, который будет содержать разметку этой модели, чтобы добавить ее на сцену. Воспользуемся официальным ресурсом от разработчиков @react-three/fiber .
Для перехода к конвертеру вам потребуется загрузить преобразованный файл Weapon.glb .
Используя перетаскивание или поиск в Проводнике, найдите этот файл и загрузите его.
В конвертере мы увидим сгенерированный реактив-компонент, код которого перенесём в наш проект в новом файле WeaponModel.jsx , изменив имя компонента на то же имя, что и файл.
Теперь импортируем созданную модель в сцену. В файл App.jsx добавьте компонент WeaponModel .
На этом этапе нашей сцены ни один из объектов не отбрасывает тени.
Чтобы включить тени на сцене, вам необходимо добавить атрибут Shadows в компонент Canvas .
Далее нам нужно добавить новый источник света. Несмотря на то, что у нас на сцене уже есть AmbientLight , он не может создавать тени для объектов, поскольку не имеет направленного луча света. Итак, давайте добавим новый источник света под названием directedLight и настроим его. Атрибут, позволяющий включить режим тени « cast », — castShadow . Именно добавление этого параметра указывает на то, что данный объект может отбрасывать тень на другие объекты.
После этого добавим к компоненту Ground еще один атрибут getShadow , который означает, что компонент в сцене может получать и отображать тени на себе.
Аналогичные атрибуты следует добавить и к другим объектам на сцене: кубикам и игроку. Для кубов мы добавим castShadow и getShadow , потому что они могут как отбрасывать, так и получать тени, а для игрока мы добавим только castShadow .
Давайте добавим castShadow для Player .
Добавьте castShadow и getShadow для Cube .
Если вы присмотритесь сейчас, вы обнаружите, что площадь поверхности, на которую отбрасывается тень, довольно мала. А при выходе за пределы этой области тень просто обрезается.
Причина этого в том, что по умолчанию камера захватывает только небольшую область отображаемых теней от directedLight . Мы можем использовать компонент directedLight , добавив дополнительные атрибуты Shadow-Camera-(сверху, снизу, слева, справа), чтобы расширить эту область видимости. После добавления этих атрибутов тень станет слегка размытой. Для улучшения качества мы добавим атрибутshadow-mapSize .
Теперь добавим отображение оружия от первого лица. Создайте новый компонент Weapon , который будет содержать логику поведения оружия и саму 3D-модель.
import {WeaponModel} from "./WeaponModel.jsx"; export const Weapon = (props) => { return ( <group {...props}> <WeaponModel /> </group> ); }
Давайте разместим этот компонент на том же уровне, что и RigidBody персонажа, и в хуке useFrame зададим положение и угол поворота на основе положения значений с камеры.
Чтобы сделать походку персонажа более естественной, добавим небольшое покачивание оружия во время движения. Для создания анимации мы будем использовать установленную библиотеку tween.js .
Компонент Weapon будет заключен в групповой тег, чтобы вы могли добавить ссылку на него с помощью перехватчика useRef .
Давайте добавим useState для сохранения анимации.
Давайте создадим функцию для инициализации анимации.
Объяснение кода:
- const twSwayingAnimation = new TWEEN.Tween(currentPosition) ... Создание анимации «раскачивания» объекта из текущего положения в новое.
- const twSwayingBackAnimation = new TWEEN.Tween(currentPosition) ... Создание анимации объекта, возвращающегося в исходное положение после завершения первой анимации.
- twSwayingAnimation.chain(twSwayingBackAnimation); Соединение двух анимаций, чтобы после завершения первой анимации автоматически запускалась вторая.
В useEffect мы вызываем функцию инициализации анимации.
Теперь необходимо определить момент, в течение которого происходит движение. Это можно сделать, определив текущий вектор направления персонажа.
Если произойдет движение персонажа, мы обновим анимацию и запустим ее снова, когда закончим.
Объяснение кода:
- const isMoving = направление.длина() > 0; Здесь проверяется состояние движения объекта. Если вектор направления имеет длину больше 0, это означает, что объект имеет направление движения.
- if (isMoving && isSwayingAnimationFinished) { ... } Это состояние выполняется, если объект движется и анимация «раскачивания» завершилась.
В компоненте App добавим useFrame , в котором мы будем обновлять анимацию анимации.
TWEEN.update() обновляет все активные анимации в библиотеке TWEEN.js . Этот метод вызывается для каждого кадра анимации, чтобы гарантировать плавность всех анимаций.
Код раздела:
Нам нужно определить момент выстрела, то есть момента нажатия кнопки мыши. Давайте добавим useState для хранения этого состояния, useRef для хранения ссылки на объект оружия и два обработчика событий для нажатия и отпускания кнопки мыши.
Давайте реализуем анимацию отдачи при нажатии кнопки мыши. Для этой цели мы будем использовать библиотеку tween.js .
Определим константы для силы отдачи и продолжительности анимации.
Как и в случае с анимацией покачивания оружия, мы добавляем два состояния useState для анимации отдачи и возврата в исходное положение, а также состояние со статусом окончания анимации.
Давайте создадим функции для получения случайного вектора анимации отдачи —generateRecoilOffset иgenerateNewPositionOfRecoil .
Создайте функцию для инициализации анимации отдачи. Мы также добавим useEffect , в котором укажем состояние «кадра» как зависимость, чтобы при каждом кадре анимация инициализировалась заново и генерировались новые конечные координаты.
А в useFrame добавим проверку «удержания» клавиши мыши при стрельбе, чтобы анимация стрельбы не прекращалась до тех пор, пока клавиша не будет отпущена.
Реализовать анимацию «бездействия» для персонажа, чтобы не было ощущения «зависания» игры.
Для этого давайте добавим несколько новых состояний через useState .
Давайте исправим инициализацию анимации «покачивания», чтобы использовать значения из состояния. Идея состоит в том, что разные состояния: ходьба или остановка — будут использовать разные значения анимации, и каждый раз анимация будет инициализироваться первой.
В этой части мы реализовали генерацию сцены и движение персонажа. Также мы добавили модель оружия, анимацию отдачи при стрельбе и на холостом ходу. В следующей части мы продолжим дорабатывать нашу игру, добавляя новый функционал.
Также опубликовано здесь .