Unity WebGL + React

Всем привет. Меня зовут Григорий Дядиченко, и я технический продюсер. Сегодня хотелось поговорить про Unity, веб, как его дружить с мобильными телефонами, какие есть удобные трюки и приколы, и причём тут React. Если вам интересна тема веб проектов на Unity, добро пожаловать под кат!

41b30b342c1d9b7a8a3ee7dc3c6c1201.jpg

Я очень сильно верю в то что пластмассовый мир веб победит. Ничего не надо устанавливать, идеальная модульность, даже в интеграции в приложения различных технологий есть очень много плюсов. Скажем вам нужны зачем-то AR маски в приложении. И зачем вам класть в приложение какие-то SDK, увеличивать вес приложения, если можно вставить подобную ссылку в вебвью. Да, пользователю потребуется интернет для работы функции. Но если данный функционал в приложении не является ключевым, то в таком формате плюсов больше. Так как от размера приложения в сторе очень сильно зависит конверсия в установки от той же рекламы. И когда скажем маски используются в рамках ограниченной рекламной активации, то даже для интеграции в мобильное приложение веб решение смотрится лучше, чем внедрение сложного сдк и функционала прям в приложение.

Веб обладает множеством недостатков. В большинстве вебвью не работает localstorage (в том же webview телеграма нужно каждый раз запрашивать разрешения). Помимо проблем поддержки разных браузеров, ограниченности возможностей рендера и нюансов какие технологии там доступны. Но тем не менее к 2022 году можно сказать, что веб стал в разы приятнее чем раньше. Сайт https://caniuse.com/ в целом прекрасен.

Я долго считал, что Unity и веб не совместимы смотря на какое-то невозможное время загрузки проекта и на другие проблемы, которые я видел. Но так как с 2020 года я решил больше заниматься проектами я разобрал много вариантов и вернулся к Unity. Pixi, Three, Babylon, Phaser, PlayCanvas прекрасные технологии со всего лишь одним недостатком. На них очень трудно найти разработчиков. В отличии от Unity скажем сами движки там легче, и грузятся они быстрее (меньше чем несколько секунд Unity проект грузить невозможно, но это можно обойти через UX и мы об этом поговорим).

Из-за невозможности выстроить некоторый процесс продакшена веб проекта, я решил таки покопаться «что не так с Unity» и обнаружил забавное. На самом деле в большинстве случаев проекты которые грузятся по несколько минут — проекты неправильно организованы и просто очень много весят. Само Unity грузится не так долго, и да билд меньше 5 мб веса получить сложно. Но в современном мире 5 мб — это не так критично. По минуте грузилось то, где использовалось множество ассетов, где при большом проекте не был реализован механизм OnDemand закачки и т.п. Разбирать все нюансы реализации веб проектов — это не на одну статью. Но сегодня хочется пройтись по некоторым рекомендациям и интересным подходам. Плюс разберём реализацию этого проекта, как некоего примера.

Зачем тут React?

Правильнее наверное ставить вопрос даже не про React, а про натив. Реакт — это просто один из удобных и популярных фреймворков + для него есть плагин, который мы разберём. Но все пункты ниже подходят для любой технологии нативного веба.

Шрифты и их отрисовка

Когда мы говорим про WebGL речь идёт про растровый рендер шрифтов. Что я заметил во всех технологиях, да и Unity не исключение. Для рендера шрифтов TMP формирует текстуру атлас с символами, которые потом натягиваются на Quad меши. Проблема данного подхода вес, и то как шрифт выглядит. Кривые браузер рисует симпатичнее.

События интерфейсов

Официально Unity не поддерживает мобильные телефоны. Но на самом деле на мобилках всё неплохо работает, кроме того что касается интерфейсов. Инпут филды и т.п. работают с багами, и есть очень много косяков. Делая же всё на нативном вебе всё работает идеально.

Можно скрыть загрузку

Любой игровой движок не особо нужен (да и удобен) для реализации меню, интерфейсов и т.п. Unity и адаптив вебовский — это вообще что-то на грани фентези. И в этом и заключается основной плюс натива. Грузится Unity несколько секунд (если правильно настроить проект, пожать всё что надо пожать и т.п.) И пока Unity грузится пользователь может: авторизоваться в свой аккаунт, выбрать уровень, выбрать скин для персонажа и заняться другими делами, которые потом просто будут переданы в рантайм Unity. Да даже пройти обучение. Поэтому с точки зрения пользователя — он вообще не увидит загрузки.

Вёрстка интерфейсов

Как я уже говорил. Все инструменты Unity и любого другого движка и близко не стоят с возможностями html5 + css3. Можно подумать, но есть же внутриигровые элементы и интерфейс должен с ними взаимодействовать. Сегодня мы разберём и это.

Сделаем небольшую игру

c40c56f9ab16ea1faab4128bcc454ef9.png

Разбирать реализацию чего-либо интересно на примерах + когда есть пример полезно иметь его под рукой, чтобы вспоминать «а как это вообще делается». Я не буду разбирать нюансы работы с React, что такое UseState и т.п. Так же как и глубоко углубляться в Unity. Для этого есть целые курсы. Мы же разберём примеры их взаимодействия и решения некоторых типовых задач + набор советов для веба. Для этого я подготовил два проекта примера: React контейнер и Unity игра Runner. Результат того, что получилось можно посмотреть тут. Всё собиралось на бесплатных ассетах и коленке, так что дизайн не судите строго, его можно привести в порядок.

Настройка Unity проекта

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

Убрать всё ненужное из движка

В Unity есть возможность отключать модули и пакеты, которые вам не нужны.

Если вам не нужен пространственный интерфейс, то не импортируйте TextMeshPro, он занимает много места. Так же отключайте всё неиспользуемое и не нужное. Скажем в проекте раннере PackageManager выглядит вот так.

6f93995479d1fa91be4afba79b8a16a7.png

Для уменьшения билда при отключенных пакетах важно, чтобы в Player Settings→Other Settings у вас было установлено Strip Engine Code и High.

f1448ceeb54679bdec4b51bce39f95d1.png

Включаем правильное сжатие

b777b9927157cb91dad13f2f97730fb1.png

Я предпочитаю Gzip так как его поддерживает большинство веб серверов, хотя говорят что Brotli оптимальнее.

Оптимизируем ассеты

Тут конкретных советов нет, но базово проверяйте зрительно, что у вас оптимальное разрешение текстур (жмите, включайте Crunch до тех пор, пока визуал особо не страдает). То что у вас нет Non Power Of Two текстур в проекте, а если и есть, то они упакованы в атласы. В общем тут нет никаких отличий от советов по оптимизации и в мобильном проекте.

Фикс краша на IOS

На некоторых версиях Unity и Safari на IOS у вас будет крашится Unity проект на загрузке. Это разбиралось в этой ветке форума, как и воркэраунд вокруг, лучше просто иметь ввиду, а не сразу считать, что Unity на сафари не работает.

Интеграция с React

Выбор минимального шаблона Unity

25cc7ca07fc9028773461823694f7b93.png

Нам в целом не понадобится html, который генерирует Unity. Но так как по продакшену проще финальный билд собирать из React проекта, то лучше сразу собирать минимальный. А не интегрировать сделанный на реакте контейнер в качестве Unity WebGL тимплейта. Я пробовал и так, и так, и замена файлов в React проекте в разы удобнее.

Плагин Unity Webgl React

Основная интеграция будет происходить через плагин https://react-unity-webgl.dev/. Очень удобно сделанный для связки Unity и React. Мы разберём что и как интегрируется.

Интеграция в шаблон

Сделав пустой React проект, можно добавить туда плагин. И создать компоненту UnityReactContaner который в первом приближении и нашими настройками проекта будет выглядеть вот так:

Unity React Container

import React from "react";
import { Unity, useUnityContext } from "react-unity-webgl";
function UnityContainer() {
  const { unityProvider } = useUnityContext({
    loaderUrl: "./build/UnityBuild.loader.js",
    dataUrl: "./build/UnityBuild.data.unityweb",
    frameworkUrl: "./build/UnityBuild.framework.js.unityweb",
    codeUrl: "./build/UnityBuild.wasm.unityweb",
  });
  return ;
}

UnityBuild как название формируется в зависимости от папки сборки. Если ваша папка называется по-другому или файлы, то просто переименуйте их в скрипте. В отличии от официального примера отличаются две вещи. Приписка расширения (из-за наших настроек сжатия, и если вы их будете менять они могут измениться, на что стоит обращать внимание) и то, что путь относительный (так удобнее если игру вы кладёте не в корень вашего сайта). В проект нужно закинуть необходимые Unity файлы полученные в ходе сборки, чтобы иерархия папки public в проекте выглядела как-то так:

571857cb3ad35796ba859f7caf2da16f.png

Пишем из Unity в React App

Данный функционал позволяет на узнать. Когда Unity будет загружен, а так же использовать функционал коллбеков, чтобы передавать значения из Unity в React приложение. Для примера разберём WebUtils.jslib. Пока заведём в нём метод для передачи счёта:

WebUtils.jslib

var LibraryGLClear = {
	OnScoreUpdate: function(scoreValue){
		window.dispatchReactUnityEvent(
			"OnScoreUpdate",
			scoreValue
		);
	}
};
mergeInto(LibraryManager.library, LibraryGLClear); 

jslib в плагинах Unity проекта позволяет нам вызывать js функции из Unity. Для этого нам надо завести класс в Unity, который отвечает за вызов данного метода. В нашем проекте он называется WebglBridge и на данном этапе будет выглядеть так:

WebglBridge

using System.Runtime.InteropServices;

public static class WebglBridge
{
    [DllImport("__Internal")]
    private static extern void OnScoreUpdate(int score);
    
    public static void UpdateScore(int score)
    {
#if UNITY_WEBGL && ! UNITY_EDITOR
        OnScoreUpdate(score);
#endif
    }
}

Дальше в React приложении нам нужно подписаться на событие обновления счёта:

UnityContainer

import React from "react";
import { Unity, useUnityContext } from "react-unity-webgl";
function UnityContainer() {
  
  const [score, setScore] = useState(0);

  const { unityProvider, addEventListener, removeEventListener } = useUnityContext({
    loaderUrl: "./build/UnityBuild.loader.js",
    dataUrl: "./build/UnityBuild.data.unityweb",
    frameworkUrl: "./build/UnityBuild.framework.js.unityweb",
    codeUrl: "./build/UnityBuild.wasm.unityweb",
  });

  const handleScoreUpdate = useCallback((score)=>{
        setScore(score);
    });

  useEffect(() => {
        addEventListener("OnScoreUpdate", handleScoreUpdate);
        return () => {
            removeEventListener("OnScoreUpdate", handleScoreUpdate);
        };
    }, [addEventListener, removeEventListener, handleScoreUpdate]);

  return ;
}

В данном случае при вызове из Unity метода WebglBridge.UpdateScore (50); в реакт приложении значение score станет равным 50. И будет сказано обновить отрисовку веб приложению. Для подписки и отписки от события используются методы из useUnityContext, которые называются addEventListener и removeEventListener. А в свою очередь handleScoreUpdate это метод обработчик. Важно, чтобы название передаваемое в addEventListener и removeEventListener совпадало с названием метода в файле WebUtils.jslib.

Из удобств то, что подобное событие может обладать множеством параметров или без параметров. Что можно увидеть в финальной реализации WebUtils.jslib. Так же как и то, что для текста нужно переводить переменную в правильную кодировку.WebUtils.jslib

WebUtils.jslib

var LibraryGLClear = {
	OnGameOver: function(){
		window.dispatchReactUnityEvent(
		  "OnGameOver"
		);
	},
	OnScoreUpdate: function(scoreValue){
		window.dispatchReactUnityEvent(
			"OnScoreUpdate",
			scoreValue
		);
	},
	OnFloatingText: function(x, y, text){
		window.dispatchReactUnityEvent(
			"OnFloatingText",
			x,
			y,
			UTF8ToString(text)
		);
	}
};
mergeInto(LibraryManager.library, LibraryGLClear); 

Пишем из React App в Unity

Этот механизм я бы сказал устроен классически. Так как в useUnityContext так же есть метод под названием sendMessage. Который работает как самый обычный Unity SendMessage. В репозитории приводится пример использования без параметров. Но скажем если мы хотим иметь возможность из React приложения ставить игру на паузу, то нам нужно сначала завести класс ReactEventsHandler:

ReactEventsHandler

using UnityEngine;

public class ReactEventsHandler : MonoBehaviour
{
    public void Pause()
    {
        Time.timeScale = 0;
    }
}

Дальше мы делаем добавляем объект, который будет принимать SendMessage в сцену (название объекта важно, но его не обязательно называть так же, как и класс):

03e96b7fdfcf050f9e2a0256812bf7b4.png

И в нашем UnityContainer мы просто делаем функцию handlePauseButton где делаем sendMessage («ReactEventsHandler», «Pause»); и вызываем её по какому-то событию, скажем нажатию кнопки:

UnityContainer

import React from "react";
import { Unity, useUnityContext } from "react-unity-webgl";
function UnityContainer() {
  
  const [score, setScore] = useState(0);
  const [isPause, setPause] = useState(true);
  
  const { unityProvider, addEventListener, removeEventListener, 
         sendMessage } = useUnityContext({
    loaderUrl: "./build/UnityBuild.loader.js",
    dataUrl: "./build/UnityBuild.data.unityweb",
    frameworkUrl: "./build/UnityBuild.framework.js.unityweb",
    codeUrl: "./build/UnityBuild.wasm.unityweb",
  });
  
  function handlePauseButton(){
        sendMessage("ReactEventsHandler", "Pause");
        setPause(true);
  }
  
  const handleScoreUpdate = useCallback((score)=>{
        setScore(score);
    });

  useEffect(() => {
        addEventListener("OnScoreUpdate", handleScoreUpdate);
        return () => {
            removeEventListener("OnScoreUpdate", handleScoreUpdate);
        };
    }, [addEventListener, removeEventListener, handleScoreUpdate]);

  return (
); }

На всякий случай повторю, что «ReactEventsHandler» — это имя объекта, а не класса и в этом надо быть внимательным. В данном примере у нас появится кнопка, которая будет ставить игру на паузу. Остальные доработки можно посмотреть в репозиториях. Но ещё один концепт мы разберём.

Скрываем загрузку

В чём основная прелесть подобного контейнера, это в том что можно скрыть загрузку Unity. Конечно в примере я не стал делать «ввод никнейма» и целый набор экранов. Нечто похожее можно посмотреть тут. Тут уже скрывается получше. Но сделаем просто экран с подсказкой управления.

Для этого заведём компоненту Onboarding:

import {isMobile} from "../../utils/utils";
import "./Onboarding.css"
export function Onboarding(props){
    return 
{isMobile.any() ? "Управляйте роботом с помощь свайпа" : "Управляйте роботом клавишами A/D или стрелочками" }
}

Пропишем стили:

.onboarding{
    position: fixed;
    left: 0;
    top: 0;
    width: 100vw;
    height: 100vh;
    z-index: 15;
    backdrop-filter: blur(10px);
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
}
.onboarding-text{
    padding-bottom: 40px;
    font-size: 18px;
}

И интегрируем её в контейнер. Для этого в useUnityContext есть замечательное поле isLoaded, которое показывает статус загрузки нашего Unity приложения. Выглядеть наш UnityContaner будет как-то так:

UnityContainer

import React from "react";
import { Unity, useUnityContext } from "react-unity-webgl";
function UnityContainer() {
  
  const [score, setScore] = useState(0);
  const [isPause, setPause] = useState(true);
  const [isShowOnboarding, setOnboarding] = useState(true);
  
  const { unityProvider, addEventListener, removeEventListener,
        sendMessage, isLoaded} = useUnityContext({
    loaderUrl: "./build/UnityBuild.loader.js",
    dataUrl: "./build/UnityBuild.data.unityweb",
    frameworkUrl: "./build/UnityBuild.framework.js.unityweb",
    codeUrl: "./build/UnityBuild.wasm.unityweb",
  });
  
  function handlePauseButton(){
        sendMessage("ReactEventsHandler", "Pause");
        setPause(true);
  }

  function handleStartButton(){
      handleResumeButton();
      setOnboarding(false);
  }
  
  const handleScoreUpdate = useCallback((score)=>{
        setScore(score);
    });

  useEffect(() => {
        addEventListener("OnScoreUpdate", handleScoreUpdate);
        return () => {
            removeEventListener("OnScoreUpdate", handleScoreUpdate);
        };
    }, [addEventListener, removeEventListener, handleScoreUpdate]);

  return (
{isShowOnboarding? : ""}
); }

где handleStartButton — это обработчик кнопки начать, при том что онбординг выглядит как-то так:

a8f267dedd33c1691ab39c48b687a8c0.png

Я реализовал простейший пример, но если у вас в проекте/игре, есть авторизация, авторизация через соц. сети, профиль пользователя, предварительный выбор каких-то настроек без геймплея. То игра по умолчанию ставится на паузу или в пустой сцене, но юнити контейнер мы всё равно грузим. И в таком случае когда пользователь дойдёт до плеера геймплея, то он уже не увидит никакой загрузки.

И кстати на тему isMobile и прикольного нюанса Unity. Для этого приведу его реализацию.

export const isMobile = {
    Android: function () {
        return navigator.userAgent.match(/Android/i) || navigator.userAgent.match(/Miui/i);
    },
    BlackBerry: function () {
        return navigator.userAgent.match(/BlackBerry/i);
    },
    iOS: function () {
        return navigator.userAgent.match(/iPhone|iPad|iPod/i);
    },
    Opera: function () {
        return navigator.userAgent.match(/Opera Mini/i);
    },
    Windows: function () {
        return navigator.userAgent.match(/IEMobile/i) || navigator.userAgent.match(/WPDesktop/i);
    },
    any: function () {
        return (isMobile.Android() || isMobile.BlackBerry() || isMobile.iOS() || isMobile.Opera() || isMobile.Windows());
    }
};

В Unity есть своя проверка на мобильные платформы работающая по тому же принципу. И видимо взятая отсюда. Но она работает не верно, так же как и ответ на stackoverflow на устройствах Xiaomi, так как Mi Browser говорит, что он Linux. Поэтому я добавил доп. проверку чисто на Mi Browser и эта реализация работает корректнее.

Floating Text

Ещё примера я решил сделать отлетающий текст при сборке очков и т.п. Допустим мы хотим, чтобы при сборе какого-то бонуса React App в котором у нас интерфейс учитывал «где мы его подобрали» и текст отлетал не откуда-то, а именно в нужную интерфейсную панельку.

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

var playerPos = _Camera.WorldToViewportPoint(_Player.transform.position);
WebglBridge.SetFloatingText(playerPos.x, playerPos.y, $"+{ScoreAddition}");

А дальше передав через коллбек эти значения в нашу компоненту FloatingText имеем такой код:


const width = 300;
const height = 50;
export function FloatingText(props){
    return 
{props.text}
}

(1 — props.y) так как вьюпорт юнити считается с левого нижнего угла, а vw и vh с левого верхнего. А так же умножаем на 100, так как в Unity вьюпорт в значениях от 0 до 1, а vw и vh в процентах.

Для анимации мы в стиле не указываем начальную позицию, но указываем конечную. Так как для простоты я работаю с fixed координатами тут так же всё просто.

.floating-text{
    position: fixed;
    color: white;
    font-size: 32px;
    z-index: 1;
    -webkit-animation: fly 1s  normal ease-out forwards;
    animation: fly 1s  normal ease-out forwards;
}

@keyframes fly {
    0% {
        color: rgba(255,255,255,255);
        -webkit-animation-timing-function: ease-in;
    }
    
    100% {
        color: rgba(255,255,255,0);
        left: 20px;
        top: 20px;
        animation-timing-function: ease-out;
    }
}

И получаем анимацию отлетающего текста из позиции в Unity в произвольную позицию. Такую же математику только сложнее можно проделать, если Unity приложение открывается не на весь экран или же в iframe, но она будет посложнее и учитывать иерархию блоков в вёрстке.

В заключение

Спасибо за внимание. Надеюсь разбор примеров в статье дал вам понимание о том, как строится интеграция Unity в React. Конечно без экспертизы в React и в JS примеры воспринимать тяжело, но если вы занимаетесь веб проектами я бы рекомендовал изучить какой-то JS Framework и Реакт для этого отлично подходит. Так как настоящий кроссплатформенный разработчик — это тот, кто знает контекст и нюансы множества платформ и умеет в них строить оптимальные решения. В конце я оставлю целиком проект, чтобы при желании вы могли его разобрать и научиться чему-то новому. Либо использовать как некий сниппет в своих будущих проектах.

Ссылка на результат
Реакт репозиторий
Unity репозиторий

© Habrahabr.ru