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的調(diào)整
html {
  background: #1e1a20;
}

/* canvas需要始終位于視圖后面 */
canvas {
  position: fixed;  
  left: 0;
  top: 0;
}
  • Set up
    • no OrbitControl
    • some HTML content
    • document.body.prepend(renderer.domElement) 將canvas插入在根節(jié)點(diǎn)之前,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) // 層級(jí)關(guān)系玄渗,將canvas插入在根節(jié)點(diǎn)之前座菠,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, 部分用戶會(huì)出現(xiàn)上拉頁(yè)面時(shí),頁(yè)面底部會(huì)彈一下藤树,導(dǎo)致出現(xiàn)與當(dāng)前頁(yè)面不一樣的色差浴滴,我們用以下方式解決,讓webgl呈現(xiàn)透明
  /**
   * 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({  // 卡通著色材質(zhì)岁钓,需要結(jié)合light使用
    color: parameters.materialColor,
  })

  // mesh
  const mesh1 = new THREE.Mesh(
    new THREE.TorusGeometry(1, 0.4, 16, 60),  // 圓環(huán)環(huán)半徑升略,管道半徑,分段數(shù)
    material
  )
  const mesh2 = new THREE.Mesh(
    new THREE.ConeGeometry(1, 2, 32),  // 圓錐底部半徑屡限,高度品嚣,側(cè)周圍分段
    material
  )
  const mesh3 = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),  // 圓環(huán)扭結(jié)環(huán)半徑,管道半徑
    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
  • 觀察上圖钧大,我們發(fā)現(xiàn)當(dāng)前使用了MeshToonMaterialmesh腰根,默認(rèn)情況下只能展示顏色和陰影,但其實(shí)這里可以展示更多樣的顏色拓型,下面我們使用3種顏色的漸變紋理
  /**
   * objects
  */

  // texture
  const textureLoader = new THREE.TextureLoader()
  const gradientTexture = textureLoader.load('../public/imgs/scroll-animation/3.jpg')
  gradientTexture.magFilter = THREE.NearestFilter // 紋理覆蓋大于1像素時(shí)额嘿,使用放大濾鏡

  // material
  const material = new THREE.MeshToonMaterial({  // 卡通著色材質(zhì),需要結(jié)合light使用
    color: parameters.materialColor,
    gradientMap: gradientTexture,  // 漸變紋理
  })
  ...
  ...
texture.png
  • Position
    • in the three.js, the field of view is vertical, 也就是說(shuō)我們的相機(jī)視野是在縱向延伸的
    • 當(dāng)object位置固定時(shí)劣挫,即使調(diào)整視口尺寸册养,物體的相對(duì)位置依然不變
    • create an objectDistance variable with a random value
    /**
     * objects
    */
    ...
    ...
    // distance
    const objectDistance = 4  // 4個(gè)單位
    ...
    
    • use the objectDistance to position the meshes on the y axis, 注意我們的object的位置是往下排列的,因此要去負(fù)值
    mesh1.position.y = - objectDistance * 0
    mesh2.position.y = - objectDistance * 1
    mesh3.position.y = - objectDistance * 2
    
    • 這時(shí)我們看到第一部分只有mesh1了压固,但當(dāng)我們滾動(dòng)頁(yè)面時(shí)球拦,只有html部分在滾動(dòng),objects部分并沒(méi)有一起滾動(dòng),稍后會(huì)解決這個(gè)問(wèn)題
      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()
  • 添加旋轉(zhuǎn)后坎炼,接下來(lái)解決滾動(dòng)的問(wèn)題愧膀,當(dāng)我們滾動(dòng)頁(yè)面時(shí),如何能看到對(duì)應(yīng)sectionmesh呢谣光,那就需要讓camera跟著一起滾動(dòng)
    • 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個(gè)重要的問(wèn)題檩淋,那就是camera的移動(dòng)方向和移動(dòng)距離
    /**
     * 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 是滾動(dòng)的像素值蟀悦,但camera移動(dòng)的是單位
      // 3. 當(dāng)camera滾動(dòng)了window.innerHeight的距離后,應(yīng)該展示下一個(gè)section氧敢,
      //    也就是說(shuō)camera應(yīng)該到達(dá)下一個(gè)mesh了
      camera.position.y = - scrollY / window.innerHeight * objectDistance
    
      // animate meshes
      ...
    
      ...
    }
    tick()
    
scroll.png
  • Position Objects 當(dāng)前所有的mesh都是水平居中的日戈,修改一下mesh的位置與每部分的文字合理分布
  /**
   * objects
  */
  ...
  ...
  mesh1.position.x = 2
  mesh2.position.x = -2
  mesh3.position.x = 2
  ...
  ...
Position Objects1.png

Position Objects2.png
  • Parallax 視差,具體要做的就是讓camera跟隨cursor移動(dòng)孙乖,然后就可以從不同角度看到整個(gè)場(chǎng)景
    • we need to retrieve the cursor position, 這一步需要注意的是浙炼,瀏覽器提供的坐標(biāo)系是左上角為(0, 0),那也就意味著cursor的像素位置信息始終是正數(shù)唯袄,而camera最終需要的是可以上下左右移動(dòng)弯屈,也就是正負(fù)值都要有,因此在這一步我們需要做一個(gè)坐標(biāo)系轉(zhuǎn)換越妈,將左上角為(0, 0)轉(zhuǎn)換為中心點(diǎn)為(0, 0)季俩,特別注意這里只是原點(diǎn)位置變更了钮糖,但是坐標(biāo)軸的方向是不變的梅掠,依然還是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, 創(chuàng)建視差變量店归,在這一步完成之后移動(dòng)光標(biāo)時(shí)阎抒,會(huì)發(fā)現(xiàn)2個(gè)問(wèn)題,第一是光標(biāo)左右移動(dòng)時(shí)mesh會(huì)對(duì)應(yīng)分別向左消痛、向右且叁,但上下移動(dòng)時(shí),mesh的位移和光標(biāo)同步了秩伞;第二逞带,滾動(dòng)頁(yè)面時(shí),僅僅只是頁(yè)面滾動(dòng)纱新,camera并未跟隨滾動(dòng)
    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軸正軸是垂直向下的展氓,因此當(dāng)cursor向下移動(dòng)時(shí),camera.position.y的值是正數(shù)脸爱,那么camera也對(duì)應(yīng)向上移動(dòng)(此時(shí)camera呈現(xiàn)的mesh是向下的)
    const tick = () => {
      ...
      // animate camera
      camera.position.y = - scrollY / window.innerHeight * objectDistance
     
      // parallax 
      // cursor移動(dòng)的距離*0.5  減小視差變化的幅度
      const parallaxX = cursor.x * 0.5
      const parallaxY = - cursor.y * 0.5 // 瀏覽器作坐標(biāo)系與camera所處的坐標(biāo)系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 // 當(dāng)前幀和上一幀的時(shí)間差
      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 是滾動(dòng)的像素值,但camera移動(dòng)的是單位
      // 3. 當(dāng)camera滾動(dòng)了window.innerHeight的距離后空入,應(yīng)該展示下一個(gè)section络它,
      //    也就是說(shuō)camera應(yīng)該到達(dá)下一個(gè)mesh了
      camera.position.y = - scrollY / window.innerHeight * objectDistance
    
      // parallax 
      // cursor移動(dòng)的距離*0.5  減小視差變化的幅度
      const parallaxX = cursor.x * 0.5
      const parallaxY = - cursor.y * 0.5 // 瀏覽器作坐標(biāo)系與camera所處的坐標(biāo)系y軸方向相反
      // 使動(dòng)畫更緩慢流暢,而不是一下就完成動(dòng)畫
      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的同時(shí)修改particle的顏色
  /**
   * gui
   * **/
  const gui = new dat.GUI()
  const parameters = {
    materialColor: '#ffeded'
  }
  gui.addColor(parameters, 'materialColor').onChange(() => {
    material.color.set(parameters.materialColor)  // 拖動(dòng)顏色后重置
    particlesMaterial.color.set(parameters.materialColor)
  })
  • Trigger rotation 當(dāng)頁(yè)面滾動(dòng)至某個(gè)section的時(shí)候歪赢,對(duì)應(yīng)的mesh發(fā)生rotation(請(qǐng)自行安裝gsap化戳, 當(dāng)前版本"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) // 滾動(dòng)至第幾個(gè)section
    if(newSection !== currentSection) {
      currentSection = newSection
      // sectionMeshes[currentSection] 得到一個(gè)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
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市轨淌,隨后出現(xiàn)的幾起案子迂烁,更是在濱河造成了極大的恐慌,老刑警劉巖递鹉,帶你破解...
    沈念sama閱讀 206,482評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件盟步,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡躏结,警方通過(guò)查閱死者的電腦和手機(jī)却盘,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,377評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)媳拴,“玉大人黄橘,你說(shuō)我怎么就攤上這事∏龋” “怎么了塞关?”我有些...
    開封第一講書人閱讀 152,762評(píng)論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)子巾。 經(jīng)常有香客問(wèn)我帆赢,道長(zhǎng),這世上最難降的妖魔是什么线梗? 我笑而不...
    開封第一講書人閱讀 55,273評(píng)論 1 279
  • 正文 為了忘掉前任椰于,我火速辦了婚禮,結(jié)果婚禮上仪搔,老公的妹妹穿的比我還像新娘瘾婿。我一直安慰自己,他們只是感情好烤咧,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,289評(píng)論 5 373
  • 文/花漫 我一把揭開白布偏陪。 她就那樣靜靜地躺著,像睡著了一般煮嫌。 火紅的嫁衣襯著肌膚如雪笛谦。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,046評(píng)論 1 285
  • 那天立膛,我揣著相機(jī)與錄音揪罕,去河邊找鬼梯码。 笑死,一個(gè)胖子當(dāng)著我的面吹牛好啰,可吹牛的內(nèi)容都是我干的轩娶。 我是一名探鬼主播,決...
    沈念sama閱讀 38,351評(píng)論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼框往,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼鳄抒!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起椰弊,我...
    開封第一講書人閱讀 36,988評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤许溅,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后秉版,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體贤重,經(jīng)...
    沈念sama閱讀 43,476評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,948評(píng)論 2 324
  • 正文 我和宋清朗相戀三年清焕,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了并蝗。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,064評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡秸妥,死狀恐怖滚停,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情粥惧,我是刑警寧澤键畴,帶...
    沈念sama閱讀 33,712評(píng)論 4 323
  • 正文 年R本政府宣布,位于F島的核電站突雪,受9級(jí)特大地震影響起惕,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜挂签,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,261評(píng)論 3 307
  • 文/蒙蒙 一疤祭、第九天 我趴在偏房一處隱蔽的房頂上張望盼产。 院中可真熱鬧饵婆,春花似錦、人聲如沸戏售。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,264評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)灌灾。三九已至搓译,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間锋喜,已是汗流浹背些己。 一陣腳步聲響...
    開封第一講書人閱讀 31,486評(píng)論 1 262
  • 我被黑心中介騙來(lái)泰國(guó)打工豌鸡, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人段标。 一個(gè)月前我還...
    沈念sama閱讀 45,511評(píng)論 2 354
  • 正文 我出身青樓涯冠,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親逼庞。 傳聞我的和親對(duì)象是個(gè)殘疾皇子蛇更,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,802評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容