用 three-js 寫一個下雨動畫

最近看了《Three.js開發指南》,深刻地意識到光看不練跟沒看差不多,所以就練習寫了這個小動畫。

項目地址: github.com/alasolala/threejs-tutorial.git

前置知識

WebGL 讓我們能在瀏覽器開發 3D 應用,然而直接使用 WebGL 編程還是挺複雜的,開發者需要知道 WebGL 的底層細節,並且學習複雜的着色語言來獲得 WebGL 的大部分功能。Three.js 提供了一系列很簡單的關於 WebGL 特性的 JavaScript API,使開發者可以很方便地創作出好看的 3D 圖形。在 Three.js 官網,就有很多酷炫 3D 效果

使用 Three.js 開發 3D 應用,通常要包括渲染器(Renderer)、場景(Scene)、照相機(Camera),以及你在場景中創建的物體,光照。

設想一下照相的情況,我們需要一個場景(Scene),在這個場景中擺好要拍攝的物體,設置光照環境,擺放好照相機(Camera)的位置和朝向,然後就可以拍照了。渲染器(Renderer)可能和攝影師比較像吧,負責下命令拍攝,並且生成圖像(照片)。

將下面的代碼的複製並運行,就可以得到一個很簡單的 3D 場景。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta >
  <title>room</title>
</head>
<body>
  <div></div>
  <script src="https://unpkg.com/three@0.119.0/build/three.js"></script>
  <script>
    function init () {
      const scene = new THREE.Scene()

      const camera = new THREE.PerspectiveCamera(45, 
        window.innerWidth / window.innerHeight,
        0.1,
        1000
      )
      camera.position.set(-30, 40, 30)
      camera.lookAt(0,0,0)
      scene.add(camera) 

      const planeGeometry = new THREE.PlaneGeometry(60,20)
      const planeMaterial = new THREE.MeshLambertMaterial({
        color: 0xAAAAAA
      })  
      const plane = new THREE.Mesh(planeGeometry, planeMaterial)
      plane.rotation.x = -Math.PI / 2
      plane.position.set(15, 0, 0)
      scene.add(plane)

      const sphereGeometry = new THREE.SphereGeometry(4, 20, 20)
      const sphereMaterial = new THREE.MeshLambertMaterial({
        color: 0xffff00
      })
      const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial)
      sphere.position.set(20, 4, 2)
      scene.add(sphere)

      const spotLight = new THREE.SpotLight(0xffffff)
      spotLight.position.set(-20, 30, -15)
      scene.add(spotLight)

      const renderer = new THREE.WebGLRenderer()
      renderer.setClearColor(new THREE.Color(0x000000))
      renderer.setSize(window.innerWidth, window.innerHeight)
      document.getElementById('webgl-output').appendChild(renderer.domElement)

      renderer.render(scene, camera)
    }

    init()
</script>
</body>
</html>

場景(Scene)

THREE.Scene 對象是所有不同對象的容器,但這個對象本身沒有很複雜的操作,我們通常在程序最開始的時候實例化一個場景,然後將照相機、物體、光源添加到場景中。

const scene = new THREE.Scene()
scene.add(camera)        //添加照相機
scene.add(plane)         //添加灰色平面
scene.add(sphere)        //添加黃色球體
scene.add(spotLight)     //添加光源

照相機(Camera)

Three.js 庫提供了兩種不同的照相機:透視投影照相機和正交投影照相機。

透視投影照相機的效果類似人眼在真實世界中看到的場景,有 "近大遠小" 的效果,垂直視平面的平行線在遠方會相交。

正交投影照相機的效果類似我們在數學幾何學課上老師教我們畫的效果,在三維空間內平行的線,在屏幕上永遠不會相交。

我們這裏用的是透視投影照相機,就主要討論它,正交投影照相機後面用到再說。

const camera = new THREE.PerspectiveCamera(
  45, 
  window.innerWidth / window.innerHeight,
  0.1,
  1000
)
camera.position.set(-30, 40, 30)
camera.lookAt(0,0,0)
scene.add(camera)

設置一個照相機分三步: 確定視野範圍, 確定照相機座標, 確定照相機聚焦點

我們在new THREE.PerspectiveCamera的時候確定照相機的視野範圍,對應上圖,45 是 fov,就是視野上下邊緣之間的夾角。window.innerWidth / window.innerHeight是視野水平方向和豎直方向長度的比值,0.1(near)和 1000(far)分別是照相機到視景體最近、最遠的距離,這些參數決定了要顯示的三維空間的範圍,也就是上圖中的灰色區域。

camera.position.set(-30, 40, 30)確定了照相機在空間中的座標。

camera.lookAt(0,0,0)確定了照相機聚焦點,該點和照相機座標的連線就是拍攝方向。

上圖中的灰色區域在屏幕上的顯示效果,也就是將三維空間的座標投影到屏幕二維座標是 webgl 完成的,我們只需要關心三維空間的座標。

座標系

與我們之前講到的 CSS 的 3D 座標系不同,webgl 座標系是右手座標系,X 軸向右,Y 軸向上,Z 軸是指向 “自己” 的。

伸出右手讓拇指和食指成"L"大拇指向右食指向上其餘的手指指向自己這樣就建立了一個右手座標系

其中拇指食指和其餘手指分別代表xyz軸的正方向

在空間中定位、平移都比較好理解,這裏看一下旋轉。

有時,我們會這樣設置物體的旋轉:object.rotation.x = -Math.PI / 2,表示的是繞 X 軸旋轉 - 90 度。具體是怎麼旋轉,就要對照上面座標系,展開右手,拇指指向 x 軸正方向,其餘手指的彎曲方向就是旋轉的正方向;拇指指向 x 軸負方向,其餘手指的彎曲方向就是旋轉的負方向。y 軸和 z 軸旋轉方向的判斷同理。

物體

在 three.js 中,創建一個物體需要兩個參數:幾何形狀(Geometry)和 材質(Material)。通俗的講,幾何形狀決定物體的形狀,材質決定物體表面的顏色、紋理貼圖、對光照的反應等等。

//創建一個平面幾何體,參數是沿X方向的Width和沿Y方向的height
const planeGeometry = new THREE.PlaneGeometry(60,20)  

//創建一種材質,MeshLambertMaterial是一種考慮漫反射而不考慮鏡面反射的材質
const planeMaterial = new THREE.MeshLambertMaterial({
  color: 0xAAAAAA
})  

//根據幾何形狀和材質創建物體
const plane = new THREE.Mesh(planeGeometry, planeMaterial)

//設置物體的位置和旋轉,並將物體加到場景(scene)中
plane.rotation.x = -Math.PI / 2
plane.position.set(15, 0, 0)
scene.add(plane)

一些常用的幾何形狀和材質可以參考 Three.js 入門指南

光照

沒有光源,渲染的場景將不可見(除非你使用基礎材質或線框材質,當然,在構建 3D 應用時,幾乎不怎麼用基礎材質和線框材質)。

WebGL 本身並不支持光源。如果不使用 Three.js,則需要自己寫 WebGL 着色程序來模擬光源。Three.js 讓光源的使用變得簡單。

const spotLight = new THREE.SpotLight(0xffffff)
spotLight.position.set(0, 0, 100)
scene.add(spotLight)

如上所示,我們只需要創建一個光源,並將它加入到場景中就可以了。three.js 會根據光源的類型、位置等信息計算出場景中各個物體的展示效果。

最常用的幾種光源是 AmbientLight、PointLight、SpotLight、DirectionalLight。

渲染器(Renderer)

當場景中的照相機、物體、光照等準備就緒,就該渲染器上場了。

在上面那個小例子中,我們是這樣使用渲染器的:

//new 一個渲染器
const renderer = new THREE.WebGLRenderer()

//設置畫布背景色,也就是畫布中沒有物體的地方的顯示顏色
renderer.setClearColor(new THREE.Color(0x000000))

//設置畫布大小
renderer.setSize(window.innerWidth, window.innerHeight)

//將畫布元素(即renderer.domElement,它是一個canvas元素)掛載到一個dom節點
document.getElementById('webgl-output').appendChild(renderer.domElement)

//執行渲染操作,參數是上面定義的場景(scene)和照相機(camera)
renderer.render(scene, camera)

可以看出,使用 Three.js 開發 3D 應用,我們只需要關心場景中物體、照相機、光照等在三維空間中的佈局,以及運動,具體怎麼渲染都由 Three.js 去完成。當然,懂一些 webgl 的基本原理會更好,畢竟有一些應用會複雜到 three.js 的 API 滿足不了要求。

實現下雨動畫

初始化場景

因爲每個 3D 應用的初始化都有 scene、camera、render,所以我們把這三者的初始化封裝成一個類 Template,後面的應用初始化可以通過子類繼承這個類,以便快速搭建框架。

import {
  Scene,
  PerspectiveCamera,
  WebGLRenderer,
  Vector3,
  Color
} from 'three'

export default class Template {
  constructor () {               //各種默認選項
    this.el = document.body
    this.PCamera = {
      fov: 45,
      aspect: window.innerWidth / window.innerHeight,
      near: 1,
      far: 1000
    }
    this.cameraPostion = new Vector3(0, 0, 1)
    this.cameraLookAt = new Vector3(0,0,0)
    this.rendererColor = new Color(0x000000)
    this.rendererWidth = window.innerWidth
    this.rendererHeight = window.innerHeight
  }

  initPerspectiveCamera () {     //初始化相機,這裏是透視相機
    const camera = new PerspectiveCamera(
      this.PCamera.fov,
      this.PCamera.aspect,
      this.PCamera.near,
      this.PCamera.far,
    )
    camera.position.copy(this.cameraPostion)
    camera.lookAt(this.cameraLookAt)
    this.camera = camera
    this.scene.add(camera)
  }

  initScene () {                //初始化場景
    this.scene = new Scene() 
  }

  initRenderer () {             //初始化渲染器
    const renderer = new WebGLRenderer()
    renderer.setClearColor(this.rendererColor)
    renderer.setSize(this.rendererWidth, this.rendererHeight)
    this.el.appendChild(renderer.domElement)
    this.renderer = renderer
  }

  init () {
    this.initScene()
    this.initPerspectiveCamera()
    this.initRenderer()
  }
}

在我們的下雨動畫中,創建一個 Director 類管理動畫,它繼承自 Template 類。可以看出,它要做的事很清晰:初始化框架、修改父類的默認配置、添加物體(雲層和雨滴)、添加光照(閃電也是光照形成的)、添加霧化效果、循環渲染。

//director.js
export default class Director extends Template{
  constructor () {
    super()

    //set params
    //camera
    this.PCamera.fov = 60       //修改照相機的默認視場fov

    //init camera/scene/render
    this.init()
    this.camera.rotation.x = 1.16   //設置照相機的旋轉角度(望向天空)
    this.camera.rotation.y = -0.12
    this.camera.rotation.z = 0.27

    //add object
    this.addCloud()                  //添加雲層和雨滴
    this.addRainDrop()

    //add light
    this.initLight()                //添加光照,用PointLight模擬閃電
    this.addLightning()
    
    //add fog
    this.addFog()                   //添加霧,在相機附近視野清晰,距離相機越遠,霧的濃度越高

    //animate
    this.animate()                 //requestAnimationFrame實現動畫
  }
}

創建不斷變換的雲層

我們首先創建一個平面,將一小朵雲做爲材質,得到一個雲朵物體。然後將很多雲朵物體進行疊加,得到一團雲。

//Cloud.js
const texture = new TextureLoader().load('/images/smoke.png')  //加載雲朵素材
const cloudGeo = new PlaneBufferGeometry(564, 300)   //創建平面幾何體
const cloudMaterial = new MeshLambertMaterial({   //圖像作爲紋理貼圖,生成材質
  map: texture,
  transparent: true
})
export default class Cloud {
  constructor () {      
    const cloud = new Mesh(cloudGeo, cloudMaterial)   //生成雲朵物體
    cloud.material.opacity = 0.6
    this.instance = cloud
  }

  setPosition (x,y,z) {
    this.instance.position.set(x,y,z)
  }

  setRotation (x,y,z) {
    this.instance.rotation.x = x
    this.instance.rotation.y = y
    this.instance.rotation.z = z
  }

  animate () {
    this.instance.rotation.z -= 0.003            //雲朵的運動是不斷繞着z軸旋轉
  }
}

在 Director 類中,生成 30 個雲朵物體,隨機設置它們的位置和旋轉,形成鋪開和層疊的效果。在循環渲染時調用雲朵物體的 animate 方法。

//director.js
addCloud () {
  this.clouds = []
  for(let i = 0; i < 30; i++){
    const cloud = new Cloud()
    this.clouds.push(cloud)
    cloud.setPosition(Math.random() * 1000 - 460, 600, Math.random() * 500 - 400)
    cloud.setRotation(1.16, -0.12, Math.random() * 360)
    this.scene.add(cloud.instance)
  }
}
animate () {
    //cloud move
    this.clouds.forEach((cloud) => {  //調用每個雲朵物體的animate方法,形成整個雲層的不斷變換效果
      cloud.animate()
    })
    ...
    this.renderer.render(this.scene, this.camera)
    requestAnimationFrame(this.animate.bind(this))
  }

環境光和閃電

同時使用了 AmbientLight 和 DirectionalLight 作爲整個場景的穩定光源,增強對現實場景的模擬。

//director.js
initLight () {
  const ambientLight = new AmbientLight(0x555555)
  this.scene.add(ambientLight)

  const directionLight = new DirectionalLight(0xffeedd)
  directionLight.position.set(0,0,1)
  this.scene.add(directionLight)
}

用 PointLight 模擬閃電,首先是初始一個 PointLight。

//director.js
addLightning () {
  const lightning = new PointLight(0x062d89, 30, 500, 1.7)
  lightning.position.set(200, 300, 100)
  this.lightning = lightning
  this.scene.add(lightning)
}

在循環渲染時,不斷隨機改變點光源 PointLight 的強度(power),形成閃爍的效果,當強度較小,即光線暗下來時,"悄悄" 改變點光源的位置,這樣就能不突兀使閃電隨機地出現在雲層地各個位置。

//director.js
animate () {
  ...
  //lightning
  if(Math.random() > 0.93 || this.lightning.power > 100){
    if(this.lightning.power < 100){
      this.lightning.position.set(
        Math.random() * 400,
        300 + Math.random() * 200,
        100
      )
    }
    this.lightning.power = 50 + Math.random() * 500
  }

  this.renderer.render(this.scene, this.camera)
  requestAnimationFrame(this.animate.bind(this))
}

創建雨滴

創建雨滴用到的粒子效果。創建一組粒子,直觀的方法是,創建一個粒子物體,然後複製 N 個,分別定義它們的位置和旋轉。

當你使用少量的對象時,這很有效,但是當你想使用大量的 THREE.Sprite 對象時,你會很快遇到性能問題,因爲每個對象需要分別由 Three.js 進行管理。

Three.js 提供了另一種方式來處理大量的粒子,這需要使用 THREE.Points。通過 THREE.Points,Three.js 不再需要管理大量單個的 THREE.Sprite 對象,而只需管理 THREE.Points 實例。

使用 THREE.Points,可以非常容易地創建很多細小的物體,用來模擬雨滴、雪花、煙和其他有趣的效果。

THREE.Points 的核心思想,就是先聲明一個幾何體 geom,然後確定幾何體各個頂點的位置,這些頂點的位置將會是各個粒子的位置。通過 PointsMaterial 確定頂點的材質 material,然後 new Points(geom, material),根據傳入的幾何體和頂點材質生成一個粒子系統。

粒子的移動: 粒子的位置座標是由一組數字確定const positions = this.geom.attributes.position.array,這組數字,每三個數確定一個座標點(x\y\z),所以要改變粒子的 X 座標,就改變positions[ 3n ] (n 是粒子序數);同理,Y 座標對應的是positions[ 3n+1 ],Z 座標對應的是positions[ 3n+2 ]

//RainDrop.js
export default class RainDrop {
  constructor () {
    const texture = new TextureLoader().load('/images/rain-drop.png')
    const material = new PointsMaterial({    //用圖片初始化頂點材質
      size: 0.8,
      map: texture,
      transparent: true
    })
    
    const positions = []

    this.drops = 8000
    this.geom = new BufferGeometry()
    this.velocityY = []

    for(let i = 0; i < this.drops; i++){
      positions.push( Math.random() * 400 - 200 )
      positions.push( Math.random() * 500 - 250 )
      positions.push( Math.random() * 400 - 200 )
      this.velocityY.push(0.5 + Math.random() / 2)  //初始化每個粒子的座標和粒子在Y方向的速度
    }
    
    //確定各個頂點的位置座標
    this.geom.setAttribute( 'position', new Float32BufferAttribute( positions, 3 ) )  
    this.instance = new Points(this.geom, material)  //初始化粒子系統
  }

  animate () {
    const positions = this.geom.attributes.position.array;
    
    for(let i=0; i<this.drops * 3; i+=3){    //改變Y座標,加速運動
      this.velocityY[i/3] += Math.random() * 0.05
      positions[ i + 1 ] -=  this.velocityY[i/3]
      if(positions[ i + 1 ] < -200){
        positions[ i + 1 ] =  200
        this.velocityY[i/3] = 0.5 + Math.random() / 2
      } 									
    }
    this.instance.rotation.y += 0.002    
    this.geom.attributes.position.needsUpdate = true
  }
}

將雨滴粒子添加到場景中,並在循環渲染時,調用 RainDrop 的 animate 方法:

//director.js
addRainDrop () {
  this.rainDrop = new RainDrop()
  this.scene.add(this.rainDrop.instance)
}
animate () {
  //rain drop move
  this.rainDrop.animate() 
  ...
  this.renderer.render(this.scene, this.camera)
  requestAnimationFrame(this.animate.bind(this))
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://juejin.cn/post/6940542710709223432