Привет, друзья!
В данном туториале я покажу вам самый простой и быстрый, хотя и не очень оптимальный с точки зрения размера сборки, способ рендеринга 3D-объектов и моделей в React.
Мы решим 3 интересные задачи:
- рендеринг самописного 3D-объекта;
- рендеринг готовой 3D-модели;
- совместный рендеринг объекта и модели.
Знание вами основ работы с трехмерной графикой в браузере является опциональным.
Источником вдохновения для меня послужила эта замечательная статья.
Если вам это интересно, прошу под кат.
Для работы с 3D-графикой будут использоваться следующие библиотеки:
- Three.js — библиотека, облегчающая работу с WebGL;
-
React Three Fiber — абстракция над
Three.js
дляReact
(компоненты); -
React Three Drei — абстракция над
React Three Fiber
(вспомогательные функции).
Еще несколько полезных ссылок:
- текстуры;
- 3D-модели;
-
glTF Pipeline —
CLI
для оптимизации файлов glTF; -
glTFJSX —
CLI
для преобразования моделейglTF
в компонентыReact
.
Для работы с зависимостями будет использоваться 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). В качестве геометрии используется один из примитивов, предоставляемых three
— SphereGeometry. В качестве аргументов сфере передается радиус (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)
rkfg
09.12.2022 00:15+1Как с производительностью дела? Всё-таки декларативный (точнее, реактивный) подход для 3D вряд ли самый оптимальный.
fire_engel
09.12.2022 21:13это, конечно, познавательно, но я ожидал чего-то большего, результат в статье даже близко не напоминает превью. Помню как-то видел потрясающую 3д-анимацию голографического скелета, состоящего из тысяч партиклей, вот это было мощно
oterman
спасибо было полезно????????????