Three.js 进阶之旅:滚动控制模型动画和相机动画 🦢

3,493 阅读9分钟

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

摘要

专栏上篇文章《Three.js 进阶之旅:页面平滑滚动-王国之泪》 讲解并实现了如何使用 R3F 进行页面图片平滑滚动,本文内容在上节的基础上,学习如何使用滚动控制 ScrollControls 来控制模型的的动画播放和相机动画,通过滚动鼠标滚轮或者上下移动触摸板,来控制模型的动画播放进度或者相机的方位视角,从而呈现出惊艳的视觉效果。这种有趣的效果大家在平时浏览一些网页的时候应该经常见到,如一些 3D产品 介绍页向下滑动鼠标滚轮时产品同时旋转并根据产品的不同视角加载不同文案、或者 3D数字地球 根据滚轮的移动距离转到某个国家或地区、还有一些 个人简历 页面或时间轴页面也经常用到这种效果。通过本文的阅读和案例页面的实现,你将学习到的知识包括:R3F 生态中的 ScrollControlsHtmluseScrolluseGLTFuseAnimations 等组件和方法的基本用法、在 R3F 中加载模型并播放模型骨骼动画、通过滚动控制模型动画播放进程和相机参数、页面元素的一些 CSS 动画及页面整体丝滑滚动动画实现等。

效果

本文案例的实现效果如下图所示,页面主体元素由一个三维模型 🐸🦢、及底部的 5HTML 页面构成,页面初始加载时模型是静止的,当我们使用鼠标或触控板或直接拖动页面滚动条时 🖱,相机镜头📷 从正面近处平滑过渡到模型侧面远处,模型开始自动播放自带骨骼动画,模型动作根据页面滚动距离和滚动时速率的大小而不同。当我们点击页面顶部菜单时,页面会平滑滚动到对应位置,模型也会播放动画。

当页面逆向 👆 滚动时,相机和模型动画也会逆向变化和播放。

文章使用 GIF 可能会造成丢帧或卡顿,可以亲自打开预览链接试试,大屏访问效果更佳。

本专栏系列代码托管在 Github 仓库【threejs-odessey】后续所有目录也都将在此仓库中更新

🔗 代码仓库地址:git@github.com:dragonir/threejs-ode…

原理

如果用原生 JavaScript 实现滚动动画效果,就需要监听滚动事件和计算滚动距离。本文还是和上篇文章《hree.js 进阶之旅:页面平滑滚动-王国之泪》一样,直接使用封装好的组件 ScrollControlsScroll 来实现,它们的详细用法和原理可前往上篇文章查看。本文中最终实现的页面需要加载模型并播放它自带的骨骼动画,因此用到了以下几个 @react-three/drei 中的组件和 hooks

Html

允许我们将 HTML 内容绑定到场景中的任意对象,它将自动投影到对象上。

<Html
  as='div'                       // 包裹元素,默认为 'div'
  wrapperClass                   // 包裹元素的类名,默认为 undefined
  prepend                        // 画布后面的元素,默认为 false
  center                         // 添加 -50%/-50% css变换,默认为 false
  fullscreen                     // 与左上角对齐并填满屏幕,默认为 false
  distanceFactor={10}            // 如果设置该值,子元素将按与相机的距离进行缩放,默认为 undefined
  zIndexRange={[100, 0]}         // Z阶范围,默认为 [16777271, 0]
  portal={domnodeRef}            // 对目标容器的引用,默认为 undefined
  transform                      // 若设置 true,将进行 3d 矩阵转换,默认为 false
  sprite                         // 渲染为 sprite,仅在转换模式下生效,默认为 false
  occlude                        // 遮挡模式,默认为 false,当设置为 blending 时将开启真正混合遮挡
  castShadow                     // 产生阴影
  receiveShadow                  // 接收阴影
  // 像 Mesh 一样设置材质
  material={<meshPhysicalMaterial side={DoubleSide} opacity={0.1} />}
  // 覆盖默认定位功能
  calculatePosition={(el: Object3D, camera: Camera, size: { width: number; height: number }) => number[]}
  occlude={[ref]}                // 可以为真或 Ref<Object3D>,当为 true 时遮挡整个场景,默认为 undefined
  onOcclude={(visible) => null}  // 可见性修改时的回调,默认为 undefined
  {...groupProps}                // 支持所有 THREE.Group 属性
  {...divProps}                  // 支持所有 HTML DIV 元素属性
>
  <h1>hello</h1>
  <p>world</p>
</Html>

Html 可以通过配置 occlude 属性隐藏在几何体后面。当 Html 组件隐藏时,它将在最内部的 div 上设置 opacity 属性,如果需要添加动画效果或者控制过渡效果,可以使用 onOcclude 自定义方法。

<Html
  occlude
  onOcclude={set}
  style={{
    transition: 'all 0.5s',
    opacity: hidden ? 0 : 1,
    transform: `scale(${hidden ? 0.5 : 1})`
  }}
/>

useGLTF

它是一个使用 useLoaderGLTFLoader 的方便钩子函数,它默认使用 draco 来加载已压缩的模型文件。

useGLTF(url)
useGLTF(url, '/draco-gltf')
useGLTF.preload(url)

useAnimations

AnimationMixer 的抽象钩子方法,可以像下面这样获取到模型自带的动画信息。

const { nodes, materials, animations } = useGLTF(url)
const { ref, mixer, names, actions, clips } = useAnimations(animations)
useEffect(() => {
  actions?.jump.play()
})

THREE.MathUtils.damp

使用 dt 以类似弹簧的方式从 xy 平滑地插入一个数字,以保持与帧速率无关的运动。

.damp (x : Float, y: Float, lambda: Float, dt: Float ): Float
  • x:当前点。
  • y:目标点。
  • lambda:较高的 lambda 值可以使运动更加突然,较低的值可以使运动更加平缓。
  • dt:以秒为单位的增量时间。

实现

〇 资源引入

在文件顶部,我们按上述原理中所述,引入必需的组件和方法。

import * as THREE from 'three';
import { Suspense, useEffect } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { ScrollControls, Html, useScroll, useGLTF, useAnimations } from '@react-three/drei';

① 场景初始化

场景初始化非常简单,只需像下面这样添加 R3F<Canvas /> 组件,并初始化相机位置即可。

export default function Experience() {
  return (
    <>
      <Canvas camera={{ position: [0, 0, 0] }}></Canvas>
    </>
  );
}

② 加载模型

我们先定义一个 ShubaDuck 类用来表示模型元素,然后使用 useGLTF 加载模型 🐸🦢 并使用模型文件的 scene 进行渲染。然后在 Canvas 中添加模型元素,并通过 scaleposition 等属性调整模型在页面上的显示大小和位置。在页面渲染前,我们也可以使用 useGLTF.preload 对模型进行预加载,以提高页面使用体验。

function ShubaDuck({ ...props }) {
  const { scene, animations } = useGLTF('./models/duck.glb')
  return <primitive object={scene} {...props} />
}
useGLTF.preload('./models/duck.glb');

<Canvas camera={{ position: [0, 0, 0] }}>
  <ShubaDuck scale={8} position={[0, -7, 0]} />
</Canvas>

以下为原始模型文件,下载完模型后可以我们可以在 Blender 中查看模型结构和动画信息,删除修改不需要的元素,最后压缩导出。

页面中完成模型加载并渲染。

🔗 模型文件来源:sketchfab.com/3d-models/s…

③ 播放模型骨骼动画

我们从模型中拿到内置的骨骼动画 animations,然后使用 useAnimations 获取到所有的动作,可以根据动作的名称进行动画播放比如本例中是 LironShuba。可以在 useEffect 中对动作使用 play() 方法进行播放,本例中的 reset()fadeIn() 方法都是可选的,它们的作用分别是开始前重置动作和模型入场动画类型。

function ShubaDuck({ ...props }) {
  const { scene, animations } = useGLTF('./models/duck.glb')
  const { actions } = useAnimations(animations, scene)
  // 播放模型动画
  useEffect(() => void (actions['LironShuba'].reset().fadeIn(0.5).play()), [actions])
  // ...
}

④ 滚动控制模型动画

现在我们来添加通过鼠标滚轮滚动来控制模型动画播放进度的功能,即当我们向下滚动页面时,模型动画正向播放,否则逆向播放,滚动速度越快,模型动画播放速度也越快。我们先在 useEffect 中将模型动画初始状态设置为 pause 暂停,然后在 useFrame 页面重绘动画钩子函数中拿到动画动作和滚动百分比 scroll.offset,设置动作播放时间 action.time,使其从初始值平滑过渡到目标值,目标值的确定可以通过整个动画播放周期时间以及页面滚动距离的长度去计算,第三个参数 lambda 也可以根据自己的页面进行调整。

function ShubaDuck({ ...props }) {
  // ...
  useEffect(() => void (actions['LironShuba'].play().paused = true), [actions])
  useFrame((state, delta) => {
    const action = actions['LironShuba']
    const offset = scroll.offset
    action.time = THREE.MathUtils.damp(action.time, (action.getClip().duration / 2) * offset, 100, delta)
    state.camera.lookAt(0, 0, 0)
  })
  // ...
}

在页面中,我们需要使用 <ScrollControls /> 组将将模型组件 <ShubaDuck /> 包裹起来。

<Canvas camera={{ position: [0, 0, 0] }}>
  <Suspense fallback={null}>
    <ScrollControls pages={4}>
      <ShubaDuck scale={10} position={[0, -10, 0]} />
    </ScrollControls>
  </Suspense>
</Canvas>

此时使用滚动控制模型动画功能就全部完成了,页面初始加载时模型是静止的,当我们滚动页面时,模型根据滚动速度和滚动距离就会自动播放对应的动画了。

💡 scroll.offset 是一个处于 [0, 1] 之间的数,表示滚动的百分比,当页面未滚动时值为 0,完全滚动到尽头时值为 1.

⑤ 滚动控制相机

useFrame 钩子函数中,我们同样可以在页面滚动时动态修改相机 📷 的位置,这样在视觉上也能形成比如模型旋转、放大缩小、显示隐藏等动画效果。本案例中使用了如下的设置,当页面从上往下滚动时,相机从模型正面变换到模型侧面更远的地方,在视觉上形成模型转身并变小的效果

useFrame((state, delta) => {
  // ...
  state.camera.position.set(Math.sin(-offset) * 50, 1, Math.cos((offset * Math.PI) / 5) * 15)
})

⑥ 页面装饰

模型部分已经全部完成了,此时我们可以使用原理中介绍的 Html 组件,将其添加到页面中,并直接用 HTMLCSS 添加一些好看的页面,与模型主题呼应。像下面这样,本文中添加了 5 个页面,每个页面都添加了不同的样式。有时间的话,我们也可以使用 gsap 等动画库,给 HTML 元素也添加一些滚动时的动画效果

<Canvas camera={{ position: [0, 0, 0] }}>
  <Suspense fallback={null}>
    <ScrollControls pages={4}>
      <Html wrapperClass='articles' occlude>
        <article className='page page1'></article>
        <article className='page page2'></article>
        <article className='page page3'></article>
        <article className='page page4'></article>
        <article className='page page5'></article>
      </Html>
      <ShubaDuck scale={10} position={[0, -10, 0]} />
    </ScrollControls>
  </Suspense>
</Canvas>

第一页

第一页有较多的页面元素,其中底部白色文字使用了一种 woff2 格式的开源卡通字体,旋转的鹅 🦢 是通过如下 CSS 动画实现的。

@keyframes rotateY
  from
    transform: perspective(400px) rotateY(0deg)
  to
    transform: perspective(400px) rotateY(360deg)

第二页

第二页是 3 个色块 🟩 通过旋转后形成的图案,给它们添加了明暗变化的动画效果。

其他页

剩下的页面使用了一些简单的文案或图片元素,等有时间再优化小吧 😂。其实还有很多细节样式和功能,如鼠标的样式是一个 🟡、向下滚动的提示语、顶部半透明的导航菜单等,具体实现可查看源码。

⑦ 点击菜单栏页面滑动动画

最后,我们来给页面顶部的菜单添加一下点击操作 🖱 。点击页面顶部导航栏菜单滑动到对应页面功能实现使用了 element.scrollIntoView 方法,可以像下面这样实现并绑定到菜单的点击事件中,此时点击菜单页面滚动时,模型动画也会同时播放。

const handleMenuClick = (className) => {
  const page = document.querySelector(className);
  page.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
}

<span className='menu' onClick={handleMenuClick.bind(this, '.page1')}></span>

🔗 源码地址: github.com/dragonir/th…

总结

本文中主要包含的知识点包括:

  • R3F 生态中的 ScrollControlsHtmluseScrolluseGLTFuseAnimations 等组件和方法的基本用法。
  • 学会在 R3F 中加载模型并播放模型骨骼动画。
  • 通过滚动控制模型动画播放进程和相机参数。
  • 在滚动页面中将模型和 HTML 元素结合起来。
  • 页面元素的一些 CSS 动画及页面整体丝滑滚动动画实现等。

想了解其他前端知识或其他未在本文中详细描述的Web 3D开发技术相关知识,可阅读我往期的文章。如果有疑问可以在评论中留言,如果觉得文章对你有帮助,不要忘了一键三连哦 👍

附录

参考