Pull to refresh
105.86
SimbirSoft
Лидер в разработке современных ИТ-решений на заказ

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

Reading time8 min
Views13K

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

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

Авторские материалы для frontend-разработчиков мы также публикуем в наших соцсетях – ВКонтакте и Telegram.

Tags:
Hubs:
Rating0
Comments4

Articles

Information

Website
www.simbirsoft.com
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия