2d-графика в React с three.js

от автора

У каждого из вас может возникнуть потребность поработать с графикой при создании React-приложения. Или вам нужно будет отрендерить большое количество элементов, причем сделать это качественно и добиться высокой производительности при перерисовке элементов. Это может быть анимация либо какой-то интерактивный компонент. Естественно, первое, что приходит в голову – это Canvas. Но тут возникает вопрос: «Какой контекст использовать?». У нас есть выбор – 2d-контекст либо WebGl. А как на счёт 2d-графики? Тут уже не всё так очевидно.

При работе над задачами с высокой производительностью мы попробовали оба решения, чтобы на практике определиться, какой из двух контекстов будет эффективнее. Как и ожидалось, 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;

А вот пример работы данного приложения.

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

После нажатия на квадрат он меняет цвет и становится белым.

Как мы видим, использование нативного 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-приложения. Каждый из этих подходов имеет свои плюсы и минусы, поэтому выбор за вами.

Спасибо за внимание! Надеемся, что наш опыт был для вас полезен.

ссылка на оригинал статьи https://habr.com/ru/company/simbirsoft/blog/560980/