У каждого из вас может возникнуть потребность поработать с графикой при создании React-приложения. Или вам нужно будет отрендерить большое количество элементов, причем сделать это качественно и добиться высокой производительности при перерисовке элементов. Это может быть анимация либо какой-то интерактивный компонент. Естественно, первое, что приходит в голову – это Canvas. Но тут возникает вопрос: «Какой контекст использовать?». У нас есть выбор – 2d-контекст либо WebGl. А как на счёт 2d-графики? Тут уже не всё так очевидно.
![](https://habrastorage.org/getpro/habr/upload_files/392/b5d/98a/392b5d98aaaf79af23856ec9d72d0271.png)
При работе над задачами с высокой производительностью мы попробовали оба решения, чтобы на практике определиться, какой из двух контекстов будет эффективнее. Как и ожидалось, WebGl победил 2d-контекст, поэтому кажется, что выбор прост.
Но тут возникает проблема. Вы сможете ощутить её, если начнете работать с документацией WebGl. С первых мгновений становится понятно, что она слишком низкоуровневая, в отличие от 2d context. Поэтому, чтобы не писать тонны кода, перед нами встаёт очевидное решение – использование библиотеки. Для реализации этой задачи подходят библиотеки pixi.js и three.js – с качественной документацией, большим количеством примеров и крупным комьюнити разработчиков.
Pixi.js или three.js
На первый взгляд, выбрать подходящий инструмент несложно: используем pixi.j для 2d-графиков, а three.js – для 3d. Однако, чем 2d отличается от 3d? По сути дела, отсутствием 3d-перспективы и фиксированным значением по третьей координате. Для того чтобы не было перспективы, мы можем использовать ортографическую камеру.
Вероятно, вы спросите: “Что за камера?”. Camera – это одно из ключевых понятий при реализации графики, наряду со scene и renderer. Для наглядности приведу аналогию. Представим, что вы стоите в комнате, держите в руках смартфон и снимаете видеоролик. Та комната, в которой вы снимаете видео – это scene. В комнате могут быть различные предметы, например, стол и стулья – это объекты на scene. В роли camera выступает камера смартфона, в роли renderer – матрица смартфона, которая проецирует 3d-комнату на 2d-экран.
Ортографическая камера отличается от перспективной, которая и используется в реальной жизни, тем, что дальние объекты в ней имеют тот же размер, что и ближние. Другими словами, если вы будете отходить от камеры, то в перспективной камере вы будете становиться меньше, а в ортографической – останетесь такими же. Можно сказать, что в этой камере нет координаты z, но это не совсем так. Она есть, но она управляет наложением одного объекта на другой.
Таким образом, three.js также подходит для 2d-графики. Так что же в итоге выбрать? Мы попробовали оба варианта и выявили на практике несколько преимуществ three.js.
Во-первых, нам нужно было выполнить интерактивное взаимодействие с элементами на сцене. Написать собственную реализацию достаточно трудозатратно, но в обеих библиотеках уже есть готовые решения: в pixi.js – из коробки, в three.js – библиотека three.interaction.
Казалось бы, в этом плане библиотеки равноценны, но это лишь первое впечатление. Особенность реализации интерактивности в pixi.js предполагает, что интерактивные элементы должны иметь заливку. Но как быть с линейными графиками? У них же нет заливки. Без собственного решения в этом случае не обойтись. Что же касается three.js, то тут этой проблемы нет, и линейные графики также интерактивны.
Еще одна задача – это экспорт в SVG. Нам нужно было реализовать функциональность, которая позволит экспортировать в SVG то, что мы видим на сцене, чтобы потом это изображение можно было использовать в печати. В three.js для этого есть готовый пример, а вот в pixi.js – нет.
Ну и будем честны с собой, в three.js больше примеров реализации тех или иных задач. К тому же, изучив эту библиотеку, при желании мы можем работать с 3d-графикой, а вот в случае pixi.js такого преимущества у нас нет.
Исходя из всего вышеописанного, наш выбор очевиден – это three.js.
Three.js и React
После выбора библиотеки мы сталкиваемся с новой дилеммой – использовать react-обертку или “каноническую” three.js.
Для react есть реализация обёртки – это react-three-fiber. На первый взгляд, в ней довольно мало документации, что может показаться проблемой. Действительно, при переносе кода из примеров three.js в react-three-fiber возникает много вопросов по синтаксису.
Однако, на практике все не так уж сложно. У этой библиотеки есть обёртка drei с неплохим storybook с готовой реализацией множества различных примеров. Впрочем, всё, что за находится за пределами этой реализации, по-прежнему может причинять боль.
Еще одна проблема – это жёсткая привязка к react. А если мы отлично реализуем view с графикой и захотим использовать где-то ещё? В таком случае снова придётся поработать.
Учитывая эти факторы, мы решили использовать каноническую three.js и написать свою собственную обертку на хуках. Если вы не хотите перебирать множество вариантов реализации, попробуйте использовать нативные ES6 классы – это хорошее и производительное решение.
Вот пример нашей архитектуры. В центре сцены нарисован квадрат, который при нажатии на него меняет цвет – с синего на серый и с серого на синий.
Создаём класс three.js для работы с библиотекой three.js. По сути, всё взаимодействие с ней будет проходить в объекте данного класса.
class Three {
constructor({
canvasContainer,
sceneSizes,
rectSizes,
color,
colorChangeHandler,
}) {
// Для использования внутри класса добавляем параметры к this
this.sceneSizes = sceneSizes;
this.colorChangeHandler = colorChangeHandler;
this.initRenderer(canvasContainer); // создание рендерера
this.initScene(); // создание сцены
this.initCamera(); // создание камеры
this.initInteraction(); // подключаем библиотеку для интерактивности
this.renderRect(rectSizes, color); // Добавляем квадрат на сцену
this.render(); // Запускаем рендеринг
}
initRenderer(canvasContainer) {
// Создаём редерер (по умолчанию будет использован WebGL2)
// antialias отвечает за сглаживание объектов
this.renderer = new THREE.WebGLRenderer({antialias: true});
//Задаём размеры рендерера
this.renderer.setSize(this.sceneSizes.width, this.sceneSizes.height);
//Добавляем рендерер в узел-контейнер, который мы прокинули извне
canvasContainer.appendChild(this.renderer.domElement);
}
initScene() {
// Создаём объект сцены
this.scene = new THREE.Scene();
// Задаём цвет фона
this.scene.background = new THREE.Color("white");
}
initCamera() {
// Создаём ортографическую камеру (Идеально подходит для 2d)
this.camera = new THREE.OrthographicCamera(
this.sceneSizes.width / -2, // Левая граница камеры
this.sceneSizes.width / 2, // Правая граница камеры
this.sceneSizes.height / 2, // Верхняя граница камеры
this.sceneSizes.height / -2, // Нижняя граница камеры
100, // Ближняя граница
-100 // Дальняя граница
);
// Позиционируем камеру в пространстве
this.camera.position.set(
this.sceneSizes.width / 2, // Позиция по x
this.sceneSizes.height / -2, // Позиция по y
1 // Позиция по z
);
}
initInteraction() {
// Добавляем интерактивность (можно будет навешивать обработчики событий)
new Interaction(this.renderer, this.scene, this.camera);
}
render() {
// Выполняем рендеринг сцены (нужно запускать для отображения изменений)
this.renderer.render(this.scene, this.camera);
}
renderRect({width, height}, color) {
// Создаём геометрию - квадрат с высотой "height" и шириной "width"
const geometry = new THREE.PlaneGeometry(width, height);
// Создаём материал с цветом "color"
const material = new THREE.MeshBasicMaterial({color});
// Создаём сетку - квадрат
this.rect = new THREE.Mesh(geometry, material);
//Позиционируем квадрат в пространстве
this.rect.position.x = this.sceneSizes.width / 2;
this.rect.position.y = -this.sceneSizes.height / 2;
// Благодаря подключению "three.interaction"
// мы можем навесить обработчик нажатия на квадрат
this.rect.on("click", () => {
// Меняем цвет квадрата
this.colorChangeHandler();
});
this.scene.add(this.rect);
}
// Служит для изменения цвета квадрат
rectColorChange(color) {
// Меняем цвет квадрата
this.rect.material.color.set(color);
// Запускаем рендеринг (отобразится квадрат с новым цветом)
this.render();
}
}
А теперь создаём класс ThreeContauner, который будет React-обёрткой для нативного класса Three.
import {useRef, useEffect, useState} from "react";
import Three from "./Three";
// Размеры сцены и квадрата
const sceneSizes = {width: 800, height: 500};
const rectSizes = {width: 200, height: 200};
const ThreeContainer = () => {
const threeRef = useRef(); // Используется для обращения к контейнеру для canvas
const three = useRef(); // Служит для определения, создан ли объект, чтобы не создавать повторный
const [color, colorChange] = useState("blue"); // Состояние отвечает за цвет квадрата
// Handler служит для того, чтобы изменить цвет
const colorChangeHandler = () => {
// Просто поочерёдно меняем цвет с серого на синий и с синего на серый
colorChange((prevColor) => (prevColor === "grey" ? "blue" : "grey"));
};
// Создание объекта класса Three, предназначенного для работы с three.js
useEffect(() => {
// Если объект класса "Three" ещё не создан, то попадаем внутрь
if (!three.current) {
// Создание объекта класса "Three", который будет использован для работы с three.js
three.current = new Three({
color,
rectSizes,
sceneSizes,
colorChangeHandler,
canvasContainer: threeRef.current,
});
}
}, [color]);
// при смене цвета вызывается метод объекта класса Three
useEffect(() => {
if (three.current) {
// Запускаем метод, который изменяет в цвет квадрата
three.current.rectColorChange(color);
}
}, [color]);
// Данный узел будет контейнером для canvas (который создаст three.js)
return <div className="container" ref={threeRef} />;
};
export default ThreeContainer;
А вот пример работы данного приложения.
При первом открытии мы получаем, как и было описано ранее, синий квадрат в центре сцены, которая имеет серый цвет.
![](https://habrastorage.org/getpro/habr/upload_files/50f/61a/b87/50f61ab875c43845539494da8fa93b98.png)
После нажатия на квадрат он меняет цвет и становится белым.
![](https://habrastorage.org/getpro/habr/upload_files/1ad/ab5/b81/1adab5b81d1e116bc342537e8a5ea0aa.png)
Как мы видим, использование нативного three.js внутри React-приложения не вызывает каких-либо проблем, и этот подход достаточно удобен. Однако, на плечи разработчика в этом случае ложится нагрузка, связанная с добавлением/удалением узлов со сцены. Таким образом, теряется тот подход, который берёт на себя virtual dom внутри React-приложения. Если вы не готовы с этим мириться, обратите внимание на библиотеку react-three-fiber в связке с библиотекой drei – этот способ позволяет мыслить в контексте React-приложения.
Рассмотрим реализованный выше пример с использованием этих библиотек:
import {useState} from "react";
import {Canvas} from "@react-three/fiber";
import {Plane, OrthographicCamera} from "@react-three/drei";
// Размеры сцены и квадрата
const sceneSizes = {width: 800, height: 500};
const rectSizes = {width: 200, height: 200};
const ThreeDrei = () => {
const [color, colorChange] = useState("blue"); // Состояние отвечает за цвет квадрата
// Handler служит для того, чтобы
const colorChangeHandler = () => {
// Просто поочерёдно меняем цвет с серого на синий и с синего на белый
colorChange((prevColor) => (prevColor === "white" ? "blue" : "white"));
};
return (
<div className="container">
{/* Здесь задаются параметры, которые отвечают за стилизацию сцены */}
<Canvas className="container" style={{...sceneSizes, background: "grey"}}>
{/* Камера задаётся по аналогии с нативной three.js, но нужно задать параметр makeDefault,
чтобы применить именно её, а не камеру заданную по умолчанию */}
<OrthographicCamera makeDefault position={[0, 0, 1]} />
<Plane
// Обработка событий тут из коробки
onClick={colorChangeHandler}
// Аргументы те же и в том же порядке, как и в нативной three.js
args={[rectSizes.width, rectSizes.height]}
>
{/* Материал задаётся по аналогии с нативной three.js,
но нужно использовать attach для указания типа прикрепления узла*/}
<meshBasicMaterial attach="material" color={color} />
</Plane>
</Canvas>
</div>
);
};
export default ThreeDrei;
Как видите, кода стало гораздо меньше и он стал более прозрачным, с точки зрения его чтения. Однако, как уже упоминалось выше, в этом случае разработчику доступно меньше примеров реализации различных компонентов. Кроме того, в процессе изучения некоторые особенности работы могут не быть очевидными, что заставляет подумать над их реализацией.
В этой статье мы с вами рассмотрели два подхода в использовании библиотеки three.js внутри React-приложения. Каждый из этих подходов имеет свои плюсы и минусы, поэтому выбор за вами.
Спасибо за внимание! Надеемся, что наш опыт был для вас полезен.
Авторские материалы для frontend-разработчиков мы также публикуем в наших соцсетях – ВКонтакте и Telegram.