Оживление моего 3D-пончика с помощью анимации

На прошлой неделе я начал улучшать свой экспериментальный сайт с пончиками. Это помогло мне снова освоиться с основами React и лучше понять основы three.js.

Я прочитал часть документации по react-three/drei, чтобы посмотреть, что я могу сделать, и получил несколько идей, которые я сразу же попытался реализовать.

Настройка анимации

Поскольку для создания пончика я следовал руководству по Blender, я также создал простую анимацию, которую хотел воспроизводить, когда пользователь нажимает на пончик.

К счастью, это оказалось намного проще, чем я думал. В коде, который я сгенерировал на основе модели, уже были реализованы некоторые хуки, которые позаботились о большей части.

const { nodes, materials, animations } = useGLTF(
  "/new-donut-for-threejs.gltf"
);
const { actions } = useAnimations(animations, group);

Я использую хук useGLTF() из пакета @react-three/drei, который использует useLoader и GLTFLoader для загрузки модели пончика.

Чтобы на самом деле воспроизвести анимацию, мне нужно использовать AnimationMixer, и чтобы сделать это легко, я могу использовать хук useAnimations, который абстрагирует AnimationMixer.

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

Как играть в анимацию

Теоретически воспроизвести анимацию довольно просто. Переменная действий имеет функции play() и stop(), и технически она работает отлично.

Я создал animationState, чтобы проверить, должен ли я запустить или остановить анимацию, и соответственно обновил переменную.

const [animationState, setAnimation] = useState(false);

const handleClick = () => {
  if(!animationState) {
    setAnimation(true);
    actions.DonutAction.play();
  } else {
    setAnimation(false);
    actions.DonutAction.stop();
  }
}

Единственная загвоздка заключалась в том, что функция stop() полностью сбрасывала анимацию. Это означает, что пончик внезапно прыгнул обратно в исходное положение. На данный момент это было нормально, но я действительно хотел найти решение, в котором анимация просто останавливалась бы там, где она была, и продолжалась бы оттуда, когда пользователь снова щелкал.

Следуй за моей мышью

Я хотел, чтобы пончик следовал за движениями мыши пользователя, чтобы весь сайт казался немного более живым. На самом деле этого было довольно легко добиться с помощью простой математики и хука useFrame.

Хук useFrame вызывается каждый кадр, что идеально подходит для реализации подобных эффектов.

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

Прежде всего, я вычислил координаты x и y на основе положения мыши и размеров области просмотра и разделил их на число, которое в основном показывает, насколько чувствителен эффект.

Затем я просто использую удобную функцию lookAt() из ссылки, которую я инициализировал ранее, и заключаю код в оператор if, потому что он должен следовать за мышью только тогда, когда анимация неактивна.

const group = useRef();

useFrame(({ mouse, viewport }) => {
  if (!animationState) {
    const x = (mouse.x * viewport.width) / 2.5;
    const y = (mouse.y * viewport.height) / 2.5;
    group.current.lookAt(x, y, 1);
  }
});

Исправление анимации

После того, как функция маленькой мыши заработала, я вернулся к решению своей проблемы с анимацией. После почти часа проб и ошибок и чтения документации я наконец нашел функцию setEffectiveTimeScale().

Чтобы понять, почему функция stop() вообще не работала, я хочу объяснить это немного подробнее. Работа над экземпляром AnimationAction, который имеет несколько свойств для отслеживания текущего состояния анимации.

Например: если вы вызываете isRunning() для действия, должны быть выполнены несколько условий, чтобы функция возвращала значение true. paused должно быть равно false, enabled должно быть равно true, timeScale не равно 0, и отложенный запуск не планируется. Последнее в данном случае не имело значения.

Так что же происходит, когда вы вызываете функцию stop()? Анимация будет немедленно остановлена ​​И сброшена, и это означает, что paused установлено в false, enabled установлено в true и time в 0. Вот почему анимация внезапно переходит в исходное состояние, поэтому мне пришлось найти другое решение.

Здесь я нашел функцию setEffectiveTimeScale(). Как следует из названия, это позволяет мне установить timeScale анимации, а чтобы остановить или, что еще лучше, приостановить анимацию, я просто устанавливаю значение 0.

Это сработало так, как я хотел. Анимация остановилась именно там, где она была приостановлена. Теперь единственная проблема заключалась в том, что я вдруг не смог продолжить анимацию. После отладки моего кода я еще больше запутался, потому что все называлось так, как я ожидал.

Итак, вернемся к чтению документации. Оказывается, вызов play() не обязательно означает, что анимация запускается немедленно. Это также связано с некоторыми условиями. Я быстро понял, в чем проблема в моей конкретной ситуации.

Цитата из документации:

Некоторые другие настройки (paused=true, enabled=false, weight=0, timeScale=0) также могут препятствовать воспроизведению анимации.

Угадайте, что я только что сделал? Точно! Я установил timeScale на 0!

Итак, на данный момент мое решение состоит в том, что я просто устанавливаю timeScale в 1 прямо перед вызовом функции play().

const handleClick = () => {
  if(!animationState) {
    setAnimation(true);
    actions.DonutAction.setEffectiveTimeScale(1)
    actions.DonutAction.play();
  } else {
    setAnimation(false);
    actions.DonutAction.setEffectiveTimeScale(0)
  }
}

Это определенно не похоже на предполагаемый способ, но на данный момент это работает.

Добавление пружин

Теперь, когда у меня заработали некоторые действительно базовые вещи, я хотел иметь больше возможных взаимодействий и эффектов с пончиком. И я уже видел несколько примеров использования react-spring в сочетании с three.js, поэтому мне захотелось попробовать!

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

Моя идея довольно проста. Когда пользователь наводит курсор на пончик, он должен увеличиваться, а когда курсор покидает пончик, он должен возвращаться к своему нормальному размеру. И react-spring на самом деле делает это довольно простым для вас.

Прежде всего, мне нужен был способ определить, навел ли пользователь курсор на пончик. Ну, самая большая проблема заключалась в том, чтобы выяснить, как на самом деле называлось свойство, потому что было около 3 разных вариантов, но onPointerOver и onPointerOut, наконец, сработали для меня.

Я быстро устанавливаю другую переменную состояния, которую я только что назвал «активной».

Название настолько удачное, что никто из тех, кто читал код позже, не знал, почему он вообще существует с первого взгляда.

В функции onPointerOver я просто вызываю setActive(true), поэтому я знаю, что пользователь в данный момент наводит курсор на пончик, а в функции onPointerOut я делаю наоборот и вызываю setActive(false).

После этого я мог инициализировать хук useSpring и изменить свойство масштаба в зависимости от активного состояния.

const { scale } = useSpring({
  scale: active ? 1.5 : 1,
});

Теперь мне нужно было только заменить элемент mesh на элемент animated.mesh, который идет с @react-spring/three, и все заработало, как задумано.

Тем временем я также переместил логику для запуска и паузы анимации из функции onClick в логику наведения, потому что для меня это было более плавным.

<animated.mesh
  scale={scale}
  onPointerOver={() => {
      setAnimation(true);
      actions.DonutAction.setEffectiveTimeScale(1)
      actions.DonutAction.play();
      setActive(true)
    }
  }
  onPointerOut={() =>{
      setAnimation(false);
      actions.DonutAction.setEffectiveTimeScale(0)
      setActive(false)
    }
  }
>
</animated.mesh>

Добавление цвета

Это было мое последнее достижение на этой неделе. Я хотел добавить больше цвета, а не только этот скучный черный фон. Я нашел хороший пример в документации React Spring для изменения цвета фона с помощью асинхронных переменных CSS. Это, вероятно, было намного сложнее, чем должно было быть, но это было приятное небольшое испытание, чтобы заставить его работать.

Итак, как я это сделал?

В компоненте приложения, где я в настоящее время реализую свой компонент пончика и который также находится в корне реагирующего приложения, я вставил код для spring.

const [{ background }] = useSpring(
  () => ({
    from: { background: "var(--step0)" },
    to: [
      { background: "var(--step1)" },
      { background: "var(--step2)" },
      { background: "var(--step3)" },
      { background: "var(--step4)" },
    ],
    config: config.molasses,
    loop: {
      reverse: true,
    },
  }),
  []
);

Я добавил переменные в файл CSS.

:root {
  --step0: #050505;
  --step1: #bad5ea;
  --step2: #fd8769;
  --step3: #ffdcb3;
  --step4: #ff615d;
}

А анимационная часть должна работать. Теперь мне нужно было только найти способ узнать, когда пользователь наводит курсор на пончик, чтобы изменить стиль корневого div на этот анимированный фон.

Что ж, мое решение довольно простое. Я еще раз создал новую переменную состояния в компоненте приложения.

const [isHovering, setIsHovering] = useState(false);

Который по какой-то причине я решил назвать правильно на этот раз.. Ну, все еще не идеально, но лучше.

Затем я реализовал функцию обратного вызова в компоненте Donut, которую я только что назвал onHover, и я передал бы ей true, если бы она вызывалась в функции onPointerOver, или false в функции onPointerOut.

<animated.mesh
	onPointerOver={() => {
	  setAnimation(true);
	  props.onHover(true);
	  actions.DonutAction.setEffectiveTimeScale(1);
	  actions.DonutAction.play();
	  setActive(true);
	}}
	onPointerOut={() => {
	  setAnimation(false);
	  props.onHover(false);
	  actions.DonutAction.setEffectiveTimeScale(0);
	  setActive(false);
	}}
>
</animated.mesh>

Глядя на это сейчас, код определенно нуждается в некотором рефакторинге, но я сделаю это позже.

Вернувшись в компонент приложения, я только что вызвал setIsHovering() в функции обратного вызова.

<DonutGLTF onHover={(isAnimating) => {setIsHovering(isAnimating)}} />

И теперь я могу внести последние изменения в корневой раздел. Поскольку это должно быть анимация, мне также нужно было изменить его на элемент animated.div, и, наконец, мне нужно было только добавить эту строку в качестве атрибута, который просто проверяет, является ли isHovering истинным, а затем добавляет фон как стиль или ноль, если он ложный. .

style={isHovering ? { background } : null}

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

Спасибо за чтение!

Источники:

Документация по реакции-три/дреи: https://github.com/pmndrs/drei

Документация для AnimationAction: https://threejs.org/docs/#api/en/animation/AnimationAction

Документация для AnimationMixer: https://threejs.org/docs/#api/en/animation/AnimationMixer

Документация по использованию-спрингу: https://react-spring.dev/docs/components/use-spring

Пример анимированного фона: https://codesandbox.io/s/8x50e?file=/src/App.tsx