three.js - Scroll Based Animation

  • how to use three.js as a background of a classic HTML page
  • make the camera to translate follow the scroll
  • discover some tricks to make it more immersive
  • add a parallax animation based on the cursor position
  • trigger some animations when arriving at the corresponding sections
  • style.css的调整
html {
  background: #1e1a20;
}

/* canvas需要始终位于视图后面 */
canvas {
  position: fixed;  
  left: 0;
  top: 0;
}
  • Set up
    • no OrbitControl
    • some HTML content
    • document.body.prepend(renderer.domElement) 将canvas插入在根节点之前,html显示在上
<script setup>
  import * as THREE from 'three'
  import * as dat from 'dat.gui'
  import gsap from 'gsap';

  /**
   * gui
  */
  const gui = new dat.GUI()

  /**
   * scene
  */
  const scene = new THREE.Scene()

  /**
   * camera
  */
  const camera = new THREE.PerspectiveCamera(
    35,
    window.innerWidth / window.innerHeight,
    0.1,
    100
  )
  camera.position.z = 6

  /**
   * objects
  */
  const geometry = new THREE.BoxGeometry(1, 1, 1)
  const material = new THREE.MeshBasicMaterial({
    color: 'red'
  })
  const mesh = new THREE.Mesh(geometry, material)
  scene.add(mesh)

  /**
   * renderer
  */
  const renderer = new THREE.WebGLRenderer()
  renderer.setSize(window.innerWidth, window.innerHeight)
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  document.body.prepend(renderer.domElement) // 层级关系,将canvas插入在根节点之前,html显示在上

  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight
    camera.updateProjectionMatrix()

    renderer.setSize(window.innerWidth, window.innerHeight)
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  })

  /**
   * tick
  */
  const tick = () => {
    renderer.render(scene, camera)
    requestAnimationFrame(tick)
  }
  tick()
</script>

<template>
  <div>
    <section class="section">
      <h1>my portfolio</h1>
    </section>
    <section class="section">
      <h1>my projects</h1>
    </section>
    <section class="section">
      <h1>contact me</h1>
    </section>
  </div>
</template>

<style scoped>
.section {
  display: flex;
  align-items: center;
  position: relative;
  height: 100vh;
  font-family: 'Cabin', sans-serif;
  color: #ffeded;
  text-transform: uppercase;
  font-size: 7vmin;
  padding-left: 10%;
  padding-right: 10%;
}
section:nth-child(even) {
  justify-content: flex-end;
}
</style>
Set up.png
  • you might notice that, if you scroll too far, you get a kind of elastic animation when the page goes beyond the limit, 部分用户会出现上拉页面时,页面底部会弹一下,导致出现与当前页面不一样的色差,我们用以下方式解决,让webgl呈现透明
  /**
   * renderer
  */
  const renderer = new THREE.WebGLRenderer({
    alpha: true
  })
  ...
  ...
  • Create objects for each section
  /**
   * gui
  */
  const gui = new dat.GUI()

  const parameters = {
    materialColor: '#ffeded'
  }
  /**
   * objects
  */
  // material
  const material = new THREE.MeshToonMaterial({  // 卡通着色材质,需要结合light使用
    color: parameters.materialColor,
  })

  // mesh
  const mesh1 = new THREE.Mesh(
    new THREE.TorusGeometry(1, 0.4, 16, 60),  // 圆环环半径,管道半径,分段数
    material
  )
  const mesh2 = new THREE.Mesh(
    new THREE.ConeGeometry(1, 2, 32),  // 圆锥底部半径,高度,侧周围分段
    material
  )
  const mesh3 = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),  // 圆环扭结环半径,管道半径
    material
  )

  scene.add(mesh1, mesh2, mesh3)
  /**
   * light
  */
  const directionalLight = new THREE.DirectionalLight('#ffffff', 1)
  directionalLight.position.set(1, 1, 0)
  scene.add(directionalLight)
  • Tweaks
  gui.addColor(parameters, 'materialColor').onChange(() => {
    material.color.set(parameters.materialColor)
  })
MeshToonMaterial.png
  • 观察上图,我们发现当前使用了MeshToonMaterialmesh,默认情况下只能展示颜色和阴影,但其实这里可以展示更多样的颜色,下面我们使用3种颜色的渐变纹理
  /**
   * objects
  */

  // texture
  const textureLoader = new THREE.TextureLoader()
  const gradientTexture = textureLoader.load('../public/imgs/scroll-animation/3.jpg')
  gradientTexture.magFilter = THREE.NearestFilter // 纹理覆盖大于1像素时,使用放大滤镜

  // material
  const material = new THREE.MeshToonMaterial({  // 卡通着色材质,需要结合light使用
    color: parameters.materialColor,
    gradientMap: gradientTexture,  // 渐变纹理
  })
  ...
  ...
texture.png
  • Position
    • in the three.js, the field of view is vertical, 也就是说我们的相机视野是在纵向延伸的
    • 当object位置固定时,即使调整视口尺寸,物体的相对位置依然不变
    • create an objectDistance variable with a random value
    /**
     * objects
    */
    ...
    ...
    // distance
    const objectDistance = 4  // 4个单位
    ...
    
    • use the objectDistance to position the meshes on the y axis, 注意我们的object的位置是往下排列的,因此要去负值
    mesh1.position.y = - objectDistance * 0
    mesh2.position.y = - objectDistance * 1
    mesh3.position.y = - objectDistance * 2
    
    • 这时我们看到第一部分只有mesh1了,但当我们滚动页面时,只有html部分在滚动,objects部分并没有一起滚动,稍后会解决这个问题
      objectDistance.png
  • Permanent Rotation
  /**
   * objects
  */
  ...
  ...
  const sectionMeshes = [mesh1, mesh2, mesh3]
  /**
   * tick
  */
  const clock = new THREE.Clock()
  const tick = () => {
    const elapsedTime = clock.getElapsedTime()

    // animate meshes
    for(const mesh of sectionMeshes) {
      mesh.rotation.x = elapsedTime * 0.1
      mesh.rotation.y = elapsedTime * 0.12
    }

    renderer.render(scene, camera)
    requestAnimationFrame(tick)
  }
  tick()
  • 添加旋转后,接下来解决滚动的问题,当我们滚动页面时,如何能看到对应sectionmesh呢,那就需要让camera跟着一起滚动
    • get and listen into scrolling direction and scrolling distance
    /**
     * scroll
    */
    let scrollY = window.scrollY
    
    window.addEventListener('scroll', () => {
      scrollY = window.scrollY
    })
    
    • in the tick function, use scrollY to make the camera move, 这一步需要明确2个重要的问题,那就是camera的移动方向和移动距离
    /**
     * tick
    */
    const clock = new THREE.Clock()
    const tick = () => {
      const elapsedTime = clock.getElapsedTime()
    
      // animate camera
      // 1. when scrolling down,scrollY is positive and getting bigger,
      //    but the camera should be go down on the y negative axis
      // 2. scrollY 是滚动的像素值,但camera移动的是单位
      // 3. 当camera滚动了window.innerHeight的距离后,应该展示下一个section,
      //    也就是说camera应该到达下一个mesh了
      camera.position.y = - scrollY / window.innerHeight * objectDistance
    
      // animate meshes
      ...
    
      ...
    }
    tick()
    
scroll.png
  • Position Objects 当前所有的mesh都是水平居中的,修改一下mesh的位置与每部分的文字合理分布
  /**
   * objects
  */
  ...
  ...
  mesh1.position.x = 2
  mesh2.position.x = -2
  mesh3.position.x = 2
  ...
  ...
Position Objects1.png

Position Objects2.png
  • Parallax 视差,具体要做的就是让camera跟随cursor移动,然后就可以从不同角度看到整个场景
    • we need to retrieve the cursor position, 这一步需要注意的是,浏览器提供的坐标系是左上角为(0, 0),那也就意味着cursor的像素位置信息始终是正数,而camera最终需要的是可以上下左右移动,也就是正负值都要有,因此在这一步我们需要做一个坐标系转换,将左上角为(0, 0)转换为中心点为(0, 0),特别注意这里只是原点位置变更了,但是坐标轴的方向是不变的,依然还是x正轴水平向右,y轴正轴垂直向下
    /**
     * cursor
    */
    const cursor = {
      x: 0,
      y: 0
    }
    // 取值范围 [-0.5, 0.5]
    window.addEventListener('mousemove', event => {
      cursor.x = event.clientX / window.innerWidth - 0.5
      cursor.y = event.clientY / window.innerHeight - 0.5
    })
    
    • use these value to move the camera, 创建视差变量,在这一步完成之后移动光标时,会发现2个问题,第一是光标左右移动时mesh会对应分别向左、向右,但上下移动时,mesh的位移和光标同步了;第二,滚动页面时,仅仅只是页面滚动,camera并未跟随滚动
    const tick = () => {
      ...
      // animate camera
      camera.position.y = - scrollY / window.innerHeight * objectDistance
     
      // parallax 
      const parallaxX = cursor.x
      const parallaxY = cursor.y
      camera.position.x = parallaxX
      camera.position.y = parallaxY
      ...
      ...
    }
    
    • y轴正轴是垂直向下的,因此当cursor向下移动时,camera.position.y的值是正数,那么camera也对应向上移动(此时camera呈现的mesh是向下的)
    const tick = () => {
      ...
      // animate camera
      camera.position.y = - scrollY / window.innerHeight * objectDistance
     
      // parallax 
      // cursor移动的距离*0.5  减小视差变化的幅度
      const parallaxX = cursor.x * 0.5
      const parallaxY = - cursor.y * 0.5 // 浏览器作坐标系与camera所处的坐标系y轴方向相反
      camera.position.x = parallaxX
      camera.position.y = parallaxY
      ...
      ...
    }
    
    • for the scroll, the problem is that we update the camera.position.y twice and the second one will replace the first one; to fix that, we're going to put the camera in a Group and apply the parallax on the Group
    /**
     * camera
    */
    // group
    const cameraGroup = new THREE.Group()
    scene.add(cameraGroup)
    
    const camera = new THREE.PerspectiveCamera(
      35,
      window.innerWidth / window.innerHeight,
      0.1,
      100
    )
    camera.position.z = 6
    
    cameraGroup.add(camera)
    
    /**
     * tick
    */
    const clock = new THREE.Clock()
    let previousTime = 0
    const tick = () => {
      const elapsedTime = clock.getElapsedTime()
      const deltaTime = elapsedTime - previousTime // 当前帧和上一帧的时间差
      previousTime = elapsedTime
    
      // animate camera
      // 1. when scrolling down,scrollY is positive and getting bigger,
      //    but the camera should be go down on the y negative axis
      // 2. scrollY 是滚动的像素值,但camera移动的是单位
      // 3. 当camera滚动了window.innerHeight的距离后,应该展示下一个section,
      //    也就是说camera应该到达下一个mesh了
      camera.position.y = - scrollY / window.innerHeight * objectDistance
    
      // parallax 
      // cursor移动的距离*0.5  减小视差变化的幅度
      const parallaxX = cursor.x * 0.5
      const parallaxY = - cursor.y * 0.5 // 浏览器作坐标系与camera所处的坐标系y轴方向相反
      // 使动画更缓慢流畅,而不是一下就完成动画
      cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime
      cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime
    
      // animate meshes
      for(const mesh of sectionMeshes) {
        mesh.rotation.x = elapsedTime * 0.1
        mesh.rotation.y = elapsedTime * 0.12
      }
    
      renderer.render(scene, camera)
      requestAnimationFrame(tick)
    }
    tick()
    
  • Particles - a good way to make experience more immersive
  /**
   * particles
  */
  const particlesCount = 200
  const positions = new Float32Array(particlesCount * 3)

  for(let i = 0; i < particlesCount; i++) {
    positions[i * 3] = (Math.random() - 0.5) * 10
    positions[i * 3 + 1] = objectDistance * 0.5 - Math.random() * objectDistance * sectionMeshes.length
    positions[i * 3 + 2] = (Math.random() - 0.5) * 10
  }

  const particlesGeometry = new THREE.BufferGeometry()
  particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

  const particlesMaterial = new THREE.PointsMaterial({
    color: parameters.materialColor,
    sizeAttenuation: true,
    size: 0.03
  })

  const particles = new THREE.Points(particlesGeometry, particlesMaterial)
  scene.add(particles)
  • 更改materialColor的同时修改particle的颜色
  /**
   * gui
   * **/
  const gui = new dat.GUI()
  const parameters = {
    materialColor: '#ffeded'
  }
  gui.addColor(parameters, 'materialColor').onChange(() => {
    material.color.set(parameters.materialColor)  // 拖动颜色后重置
    particlesMaterial.color.set(parameters.materialColor)
  })
  • Trigger rotation 当页面滚动至某个section的时候,对应的mesh发生rotation(请自行安装gsap, 当前版本"gsap": "^3.12.2"
  /**
   * scroll
   * **/
  let scrollY = window.scrollY
  let currentSection = 0

  window.addEventListener('scroll', () => {
    scrollY = window.scrollY
    const newSection = Math.round(scrollY / window.innerHeight) // 滚动至第几个section
    if(newSection !== currentSection) {
      currentSection = newSection
      // sectionMeshes[currentSection] 得到一个mesh
      gsap.to(sectionMeshes[currentSection].rotation, {
        duration: 1.5,
        ease: 'power2.inOut', // start slowly and end slowly
        x: '+=6',
        y: '+=3',
        z: '+=1.5'
      })
    }
  })
  • Done
done.png
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,039评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,223评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,916评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,009评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,030评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,011评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,934评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,754评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,202评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,433评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,590评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,321评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,917评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,568评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,738评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,583评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,482评论 2 352

推荐阅读更多精彩内容