Привет, друзья!


В данном туториале я покажу вам самый простой и быстрый, хотя и не очень оптимальный с точки зрения размера сборки, способ рендеринга 3D-объектов и моделей в React.


Мы решим 3 интересные задачи:


  • рендеринг самописного 3D-объекта;
  • рендеринг готовой 3D-модели;
  • совместный рендеринг объекта и модели.

Знание вами основ работы с трехмерной графикой в браузере является опциональным.


Источником вдохновения для меня послужила эта замечательная статья.


Если вам это интересно, прошу под кат.


Для работы с 3D-графикой будут использоваться следующие библиотеки:


  • Three.js — библиотека, облегчающая работу с WebGL;
  • React Three Fiber — абстракция над Three.js для React (компоненты);
  • React Three Drei — абстракция над React Three Fiber (вспомогательные функции).

Еще несколько полезных ссылок:



Для работы с зависимостями будет использоваться Yarn, а для создания шаблона проекта — Vite.


Репозиторий с кодом проекта.


Подготовка и настройка проекта


Создаем шаблон проекта:


# react-3d - название проекта
# react - используемый шаблон
yarn create vite react-3d --template react

Переходим в созданную директорию, устанавливаем зависимости и запускаем сервер для разработки:


cd react-3d
yarn
yarn dev

Устанавливаем дополнительные зависимости:


yarn add three @react-three/fiber @react-three/drei

И определяем минимальные стили в файле App.css:


body {
  margin: 0;
}

canvas {
  height: 100vh;
  width: 100vw;
}

Это все, что требуется для подготовки и настройки проекта.


Рендеринг 3D-объекта


Начнем с "Hello world" мира трехмерной графики — рендеринга сферы.


Рисование графики, как трехмерной, так и двумерной, в браузере осуществляется на холсте (HTML-элемент canvas). @react-three/fiber предоставляет для этого компонент Canvas:


// App.jsx
import { Canvas } from "@react-three/fiber";
import "./App.css";

function App() {
  return (
    <div className="App">
      <Canvas
        camera={{
          fov: 90,
          position: [0, 0, 3],
        }}
      >
        {/* todo */}
      </Canvas>
    </div>
  );
}

export default App;

Данный компонент содержит не только холст, но также сцену или, точнее, граф сцены (scene), камеру (camera) и рендерер (renderer). Проп camera позволяет определять настройки камеры:


  • fov — поле обзора (field of view);
  • position — положение камеры ([x, y, z]).

Создаем директорию components и в ней — файл Sphere.jsx следующего содержания:


export default function Sphere() {
  return (
    <mesh position={[0, 0, -2]}>
      <sphereGeometry args={[2, 32]} />
    </mesh>
  );
}

Обратите внимание: у нас нет необходимости импортировать элементы mesh, sphereGeometry и др. в компоненте, поскольку они включаются в глобальное пространство имен при установке three.


Что такое mesh? Если коротко, то Mesh — это структура, состоящая из геометрии или фигуры (geometry) и материала (material). В качестве геометрии используется один из примитивов, предоставляемых threeSphereGeometry. В качестве аргументов сфере передается радиус (radius) и количество сегментов ширины (widthSegments).


Результат:





Покрасим сферу в светло-зеленый цвет с помощью материала:


<mesh ref={meshRef} position={[0, 0, -2]}>
  <sphereGeometry args={[2, 32]} />
  {/* цвет можно задать как "lightgreen", но чаще используется такой формат */}
  <meshStandardMaterial color={0x00ff00} />
</mesh>

Результат:





Почему сфера черная? Чего-то явно не хватает. С точки зрения физики цвет — это ощущение, которое получает человек (или в нашем случае — камера) при попадании ему в глаз световых лучей. На нашей сцене имеется наблюдатель (камера), наблюдаемый объект (сфера), но нет света или освещения. Давайте это исправим:


// App.jsx
<Canvas
  camera={{
    fov: 90,
    position: [0, 0, 3],
  }}
>
  <ambientLight intensity={0.5} />
  <Sphere />
</Canvas>

AmbientLight — это источник окружающего света, равномерно подсвечивающий все объекты, находящиеся на сцене. Настройка intensity — интенсивность или яркость света.


Результат:





Теперь мы видим зеленый цвет. Но почему мы видим круг, а не сферу? Ответ на этот вопрос также кроется в освещении. Объем фигуры определяется светом, точнее, положением источника света, его направленностью и интенсивностью. На нашей сцене имеется только один источник света, который освещает объекты равномерно, что делает их плоскими. Добавим источник направленного света (DirectionalLight):


<Canvas
  camera={{
    fov: 90,
    position: [0, 0, 3],
  }}
>
  {/* уменьшаем интенсивность окружающего света */}
  <ambientLight intensity={0.1} />
  <directionalLight position={[1, 1, 1]} intensity={0.8} />
  <Sphere />
</Canvas>

Источник направленного света располагается немного сверху и справа от сферы, на отдалении в 1 единицу от нее.


Результат:





Уже лучше, но полноценному восприятию объема мешает слишком гладкая поверхность сферы. Давайте вместо цвета применим к сфере какую-нибудь текстуру, например, эту.


Скачиваем файл, переименовываем его в grass.jpg и помещаем в директорию public.


Для загрузки текстур в three используется TextureLoader. Импортируем его и текстуру в компоненте сферы:


import { TextureLoader } from "three/src/loaders/TextureLoader";

import texture from "/grass.jpg";

Для формирования карты текстуры @react-three/fiber предоставляет хук useLoader. Формируем карту и применяем ее к фигуре:


// !
import { useLoader } from "@react-three/fiber";
import { useRef } from "react";
import { TextureLoader } from "three/src/loaders/TextureLoader";

import texture from "/grass.jpg";

export default function Sphere() {
  // !
  const textureMap = useLoader(TextureLoader, texture);

  return (
    <mesh ref={meshRef} position={[0, 0, -2]}>
      <sphereGeometry args={[2, 32]} />
      {/* ! */}
      <meshStandardMaterial map={textureMap} />
    </mesh>
  );
}

Результат:





Отлично, с рендерингом 3D-объектов более-менее разобрались, можно двигаться дальше.


Рендеринг 3D-модели


Нам нужна готовая трехмерная модель. Раз уж в качестве 3D-объекта мы использовали сферу, возьмем что-нибудь похожее, например, эту модель планеты Земля.


Скачиваем файл в формате glTF:





Распаковываем архив в директорию earth и помещаем ее в директорию public.


Далее необходимо сделать 2 вещи:


  • оптимизировать модель с помощью gltf-pipeline;
  • преобразовать glTF в JSX.

Глобально устанавливаем gltf-pipeline:


yarn add global gltf-pipeline
# или
npm i -g gltf-pipeline

Находясь в директории earth, выполняем следующую команду:


gltf-pipeline -i scene.gltf -o model.gltf -d

Это приводит к генерации файла model.gltf. Переименовываем его в earth.gltf и выполняем следующую команду:


npx gltfjsx earth.gltf

Это приводит к генерации файла Earth.js:





Меняем расширение этого файла на .jsx и переносим его в директорию components. Редактируем его следующим образом:


import React from "react";
import { useGLTF } from "@react-three/drei";

export default function Earth() {
  const { nodes, materials } = useGLTF("/earth/earth.gltf");

  return (
    <group rotation={[-Math.PI / 2, 0, 0]}>
      <group rotation={[Math.PI / 2, 0, 0]}>
        <group rotation={[-Math.PI / 2, 0, 0]}>
          <mesh
            ref={meshRef}
            geometry={nodes.Sphere_Material002_0.geometry}
            material={materials["Material.002"]}
          />
        </group>
      </group>
    </group>
  );
}

useGLTF.preload("/earth/earth.gltf");

Импортируем и рендерим данный компонент в App.tsx:


// ...
import Earth from "./components/Earth";

function App() {
  return (
    <div className="App">
      <Canvas
        camera={{
          fov: 90,
          position: [0, 0, 3],
        }}
      >
        <ambientLight intensity={0.1} />
        <directionalLight position={[1, 1, 1]} intensity={0.8} />
        {/* ! */}
        <Earth />
      </Canvas>
    </div>
  );
}

Результат:





У нас есть Земля. Давайте заставим ее вращаться. Для этого нам потребуется ссылка на mesh и хук useFrame, предоставляемых @react-three/fiber:


// Earth.jsx
import React, { useRef } from "react";
import { useGLTF } from "@react-three/drei";
// !
import { useFrame } from "@react-three/fiber";

export default function Earth() {
  // !
  const meshRef = useRef(null);
  // requestAnimationFrame
  // поворачиваем `mesh` по оси `z` на 0.003 единицы 60 раз в секунду (зависит от платформы)
  useFrame(() => (meshRef.current.rotation.z += 0.003));

  const { nodes, materials } = useGLTF("/earth/earth.gltf");

  return (
    <group rotation={[-Math.PI / 2, 0, 0]}>
      <group rotation={[Math.PI / 2, 0, 0]}>
        <group rotation={[-Math.PI / 2, 0, 0]}>
          <mesh
            // !
            ref={meshRef}
            geometry={nodes.Sphere_Material002_0.geometry}
            material={materials["Material.002"]}
          />
        </group>
      </group>
    </group>
  );
}

useGLTF.preload("/earth/earth.gltf");

Результат:





Круто! Но хочется чего-то более рокового (ударение поставьте сами)).


Рендеринг объекта и модели


Рассмотрим пример совместного рендеринга объекта и модели. Допустим, я хочу отрендерить череп, парящий над выжженной поверхностью (why not?).


Скачиваем эту текстуру, переименовываем ее в lava.jpg и помещаем в директорию public.


Рендерим выжженную поверхность в App.jsx:


import { Canvas, useLoader } from "@react-three/fiber";
import { DoubleSide, TextureLoader } from "three";
import "./App.css";

import texture from "/lava.jpg";

function App() {
  const textureMap = useLoader(TextureLoader, texture);

  return (
    <div className="App">
      <Canvas
        camera={{
          fov: 90,
          position: [0, 0, 3],
        }}
      >
        <ambientLight intensity={0.1} />
        <directionalLight position={[1, 1, 1]} intensity={0.8} />
        {/* ! */}
        <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -1.4, 0]}>
          <planeGeometry args={[10, 10]} />
          <meshStandardMaterial map={textureMap} side={DoubleSide} />
        </mesh>
      </Canvas>
    </div>
  );
}

export default App;

PlaneGeometry — это плоский прямоугольник. Проп side со значением DoubleSide указывает применять текстуру к обеим сторонам фигуры. В противном случае, нижняя сторона прямоугольника при перемещении камеры (об этом чуть позже) будет исчезать.


Результат:





Скачиваем эту модель, распаковываем архив в директорию skull и помещаем ее в директорию public.


Повторяем шаги из предыдущего раздела для оптимизации модели и генерации компонента React.


Переименовываем файл Skull.js в Skull.jsx, перемещаем его в директорию components и редактируем следующим образом:


import { useGLTF } from "@react-three/drei";
import React, { useRef } from "react";

export default function Skull() {
  const { nodes, materials } = useGLTF("/skull/skull.gltf");

return (
    <mesh
      rotation={[-Math.PI / 2, 0, 0]}
      geometry={nodes.Object_2.geometry}
      material={materials.defaultMat}
    />
  );
}

useGLTF.preload("/skull/skull.gltf");

Импортируем и рендерим данный компонент в App.jsx:


import { Canvas, useLoader } from "@react-three/fiber";
import { DoubleSide, TextureLoader } from "three";
import "./App.css";
// !
import Skull from "./components/Skull";

import texture from "/lava.jpg";

function App() {
  const textureMap = useLoader(TextureLoader, texture);

  return (
    <div className="App">
      <Canvas
        camera={{
          fov: 90,
          position: [0, 0, 3],
        }}
      >
        <ambientLight intensity={0.1} />
        <directionalLight position={[1, 1, 1]} intensity={0.8} />
        {/* ! */}
        <Skull />
        <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -1.4, 0]}>
          <planeGeometry args={[10, 10]} />
          <meshStandardMaterial map={textureMap} side={DoubleSide} />
        </mesh>
      </Canvas>
    </div>
  );
}

export default App;

Результат:





В качестве последнего штриха добавим возможность масштабирования и вращения камеры вокруг цели или объекта наблюдения (target). По умолчанию цель рендерится на позиции с координатами 0, 0, 0. Поскольку мы не меняли позицию mesh в Skull.jsx, камера будет вращаться вокруг черепа.


Для реализации указанного функционала достаточно отрендерить компонент OrbitControls, предоставляемый @react-three/drei:


// App.jsx
// !
import { OrbitControls } from "@react-three/drei";
import { Canvas, useLoader } from "@react-three/fiber";
import { DoubleSide, TextureLoader } from "three";
import "./App.css";
import Skull from "./components/Skull";

import texture from "/lava.jpg";

function App() {
  const textureMap = useLoader(TextureLoader, texture);

  return (
    <div className="App">
      <Canvas
        camera={{
          fov: 90,
          position: [0, 0, 3],
        }}
      >
        <ambientLight intensity={0.1} />
        <directionalLight position={[1, 1, 1]} intensity={0.8} />
        {/* ! */}
        <OrbitControls />
        <Skull />
        <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -1.4, 0]}>
          <planeGeometry args={[10, 10]} />
          <meshStandardMaterial map={textureMap} side={DoubleSide} />
        </mesh>
      </Canvas>
    </div>
  );
}

export default App;

Результат:





Отключить масштабирование можно с помощью пропа enableZoom:


<OrbitControls enableZoom={false} />

Пожалуй, это все, чем я хотел поделиться с вами в этой статье.


Обратите внимание: мы рассмотрели лишь верхушку вершины айсберга под названием "работа с трехмерной графикой в браузере", так что дерзайте.


Надеюсь, вы узнали что-то новое и не зря потратили время.


Благодарю за внимание и happy coding!




Комментарии (3)


  1. oterman
    08.12.2022 21:57

    спасибо было полезно????????????


  1. rkfg
    09.12.2022 00:15
    +1

    Как с производительностью дела? Всё-таки декларативный (точнее, реактивный) подход для 3D вряд ли самый оптимальный.


  1. fire_engel
    09.12.2022 21:13

    это, конечно, познавательно, но я ожидал чего-то большего, результат в статье даже близко не напоминает превью. Помню как-то видел потрясающую 3д-анимацию голографического скелета, состоящего из тысяч партиклей, вот это было мощно