1. 程式人生 > 實用技巧 >Three.js 學習

Three.js 學習

Three.js 動效方案

背景

Three.js(下面簡稱 Three) 作為一個 3D 庫,不僅減少了我們學習 OpenGL 和 WebGL 的成本,還大大提升了前端在視覺化上給使用者帶來更多的真實、沉浸式的體驗。眾所周知,Three 更多的是用 3D 模型 + 投影相機 + 使用者互動的方式來構建一個「3D 世界」。

這張專輯,用眼睛去“聽” 活動中,在視覺在只能提供「2D 切圖」的情況下,需要營造「3D 效果」。為了獲得最好視覺體驗,僅僅通過貼圖很難做到,所以藉此機會探索了 Three 的動效方案。

運動往往是相對的,運動的本質可能是「物體動」或「相機動」,本文將從物件動畫相機動畫上闡述對 Three 的動效探索。

Three 基礎

Camera 相機

Three 提供多種相機,其中應用最廣的就是投影相機 (PerspectiveCamera) ,通過投影相機可以模擬人眼所看見的效果。

const camera = THREE.PerspectiveCamera(fov, aspect, near, far);
引數 含義 預設值
fov fov 是視景體豎直方向上(非水平!)的張角,人類有接近180度的視角大小。該值可根據具體場景所需要的視角設定。 45
aspect 指定渲染結果的橫向尺寸和縱向尺寸的比值。該值通常設定為視窗大小的寬高比。 window.innerWidth / window.innerHeight
near 表示可以看到多近的物體。這個值通常很小。 0.1
far 表示可以看到多遠的物體。這個看情況設定,過大會導致渲染過多;太小可能又會看不到。 1000

ps: 在 Three 中是沒有「長度單位」這個概念的,它的數值都是根據比例計算得出,因此這裡提到的 0.1 或 1000 都沒有具體的含義,而是一種相對長度。

可以看到,通過配置透視相機的相關引數,最終被渲染到螢幕上的,是在 nearfar 之間,根據 fov 的值和物體遠近 d 確定渲染高度,再通過 aspect 值來確定渲染寬度的。

Scene 場景

有了相機,我們還要有場景,場景是為了讓我們設定我們的空間內「有什麼」和「放在哪」的。我們可以在場景中放置物體,光源還有相機。

const scene = new THREE.Scene();

是的,建立場景就是這麼簡單。

Group

為了以群的維度去區分場景中的物體,我們還可以在場景中新增 Group。有了 Group,可以更方便地操作一類物體。
比如建立一個 stoneGroup,並新增到場景中:

const stoneGroup = new THREE.Group();
stoneGroup.name = 'stoneGroup';

scene.add(stoneGroup);

為 Group 命名,允許我們通過 name 來獲取到對應的 Group:

const group = scene.getObjectByName(name);

Geometry 幾何體

Three 提供了多種型別的幾何體,可以分為二維網格和三維網格。二維網格顧名思義只有兩個維度,可以通過這種幾何體建立簡單的二維平面;三維網格允許你定義三維物體;在 Three 中定義一個幾何體十分簡單,只需要選擇需要的幾何體並傳入相應引數建立即可。

檢視Three提供的幾何體

如果看到 Three 提供的幾何體,可以看到有的幾何體中它分別提供 GeometeryBufferGeometery 版本,關於這兩個的區別,可以看這裡 回答

大致意思就是使用 Buffer 版本的幾何體相較於普通的幾何體會將描述物體的資料存放在緩衝區中,減少記憶體消耗和 CPU 迴圈。通過它們提供的方法來看,使用 geometry 無疑是對新手友好的。

建立幾何體:

// 建立立方體,傳入長、寬和高
var cubeGeometry = new THREE.CubeGeometry(40, 40, 40);
// 建立球體,傳入半徑、寬片段數量和高片段數量
var sphereGeometry = new THREE.SphereGeometry(20, 100, 100);

Material 材質

定義材質可以幫助我們決定一個物體在各種環境情況下的具體表現。同樣 Three 也提供了多種材質。下面列舉幾個常用的材質。

名稱 描述
MeshBasicMaterial 基礎材質,用它定義幾何體上的簡單顏色或線框
MeshPhongMaterial 受光照影響,用來建立光亮的物體
MeshLambertMaterial 受光照影響,用來建立不光亮的物體
MeshDepthMaterial 根據相機遠近來決定如何給網格染色

建立材質:

var basicMaterial = new THREE.MeshBasicMaterial({ color: 0x666666 });
var lambertMaterial = new THREE.MeshLambertMaterial({ color: 0x666666 });
var phongMaterial = new THREE.MeshPhongMaterial({ color: 0x666666 });
var wireMaterial = new THREE.MeshBasicMaterial({ wireframe: true, color: 0x666666 });

更多材質和相關資訊,可以檢視 材質

Mesh網格物件

需要新增到場景中,還需要依賴 Mesh。Mesh 是用來定義材質和幾何體之間是如何粘合的,建立網格物件可以應用一個或多個材質和幾何體。

建立幾何體相同材質不同的網格物件:

var cube = new THREE.Mesh(cubeGeometry, basicMaterial);
var cubePhong = new THREE.Mesh(cubeGeometry, phongMaterial);
scene.add(cube, cubePhong);

建立材質相同幾何體不同的網格物件:

var cube = new THREE.Mesh(cubeGeometry, basicMaterial);
var sphere = new THREE.Mesh(sphereGeometry, basicMaterial);
scene.add(cube, sphere);

建立擁有多個材質幾何體的網格物件:

var phongMaterial = new THREE.MeshPhongMaterial({ color: 0x666666 });
var cubeMeshPhong = new THREE.Mesh(cubeGeometry, cubePhongMaterial);
var cubeMeshWire = new THREE.Mesh(cubeGeometry, wireMaterial);
// 網格物件新增材質
cubeMeshPhong.add(cubeMeshWire);
scene.add(cubeMeshPhong);

Renderer 渲染器

有了場景和相機,我們還需要渲染器把對應的場景用對應的相機可見渲染出來,因此渲染器需要傳入場景和相機引數。

// 抗鋸齒、canvas 是否支援 alpha 透明度、preserveDrawingBuffer 是否儲存 BUFFER 直到手動清除
const renderer = new THREE.WebGLRenderer({
    antialias: true, alpha: true, preserveDrawingBuffer: true
});
renderer.setSize(this.width, this.height);
renderer.autoClear = true;
// 清除顏色,第二個引數為 0 表示完全透明,適用於需要透出背景的場景
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(window.devicePixelRatio);

為了在相機更新後所看見的場景,需要在迴圈渲染中加上

renderer.render(scene, camera);

有了相機場景和渲染器,我們已經可以看到初步的效果了。但3D世界裡,靜止的物體多無趣啊。於是我們嘗試加入動畫效果。

物體動畫

Animations

Three為動畫提供了一系列方法。

引數 含義
AnimationMixer 作為特定物件的動畫混合器,可以管理該物件的所有動畫
AnimationAction 為播放器指定對應的片段儲存一系列行為,用來指定動畫快慢,迴圈型別等
AnimationClip 表示可重用的動畫行為片段,用來指定一個動畫的動畫效果(放大縮小、上下移動等)
KeyframeTrack 與時間相關的幀序列,傳入時間和值,應用在指定物件的屬性上。目前有 BooleanKeyframeTrack VectorKeyframeTrack 等。

那麼如何建立一個動畫呢?下面這個例子給大家解釋如何讓網格物件進行簡單的上下移動。

建立特定物件的動畫混合器:

// 建立紋理
const texture = new THREE.TextureLoader().load(img.src);
// 使用紋理建立貼圖
const material = new THREE.SpriteMaterial({ map: texture, color: 0x666666 });
// 使用貼圖建立貼圖物件
const stone = new THREE.Sprite(material);
// 為貼圖物件建立動畫混合器
const mixer = new THREE.AnimationMixer(stone);

建立動畫行為片段:

const getClip = (pos = [0, 0, 0]) => {
    const [x, y, z] = pos;
    const times = [0, 1]; // 關鍵幀時間陣列,離散的時間點序列
    const values = [x, y, z, x, y + 3, z]; // 與時間點對應的值組成的陣列
    // 建立位置關鍵幀物件:0時刻對應位置0, 0, 0   10時刻對應位置150, 0, 0
    const posTrack = new THREE.VectorKeyframeTrack('stone.position', times, values);
    const duration = 1;
    return new THREE.AnimationClip('stonePosClip', duration, [posTrack]);
};

建立動畫播放器,確定動畫的表現:

const action = mixer.clipAction(getClip([x, y, z]));
action.timeScale = 1; // 動畫播放一個週期的時間
action.loop = THREE.LoopPingPong; // 動畫迴圈型別
action.play(); // 播放

在迴圈繪製中更新混合器,保證動畫的執行:

animate() {
    // 更新動畫
    const delta = this.clock.getDelta();
    mixer.update(delta);
    
    requestAnimationFrame(() => {
        animate();
    });
}

貼圖動畫

有了 Animation 我們可以很簡單地對物體的一些屬性進行操作。但一些貼圖相關的動畫就很難用 Animation 來實現了,比如:

上圖這種,無法通過改變物體的位置、大小等屬性實現。於是,還有一種方案 —— 貼圖動畫。

類似在 CSS3 中對序列圖片使用 transform 屬性改變位置來達到的動畫效果,實際上在 Three 中也可以使用貼圖位移的方式實現。

首先,我們要有一個序列圖:

作為紋理載入,並且增加到場景中:

const arrowTexture = new THREE.TextureLoader().load(Arrow);
const material = new THREE.SpriteMaterial({ map: arrowTexture, color: 0xffffff });
const arrow = new THREE.Sprite(material);
scene.add(arrow);

宣告 TextAnimator 物件,實現紋理的位移:

function TextureAnimator(texture, tilesHoriz, tilesVert, numTiles, tileDispDuration) {
    // 紋理物件通過引用傳入,之後可以直接使用update方法更新紋理位置
    this.tilesHorizontal = tilesHoriz;
    this.tilesVertical = tilesVert;
    // 序列圖中的幀數
    this.numberOfTiles = numTiles;
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.repeat.set(1 / this.tilesHorizontal, 1 / this.tilesVertical);

    // 每一幀停留時長
    this.tileDisplayDuration = tileDispDuration;

    // 當前幀停留時長
    this.currentDisplayTime = 0;

    // 當前幀
    this.currentTile = 0;

    // 更新函式,通過這個函式對紋理位移進行更新
    this.update = (milliSec) => {
        this.currentDisplayTime += milliSec;
        while (this.currentDisplayTime > this.tileDisplayDuration) {
            this.currentDisplayTime -= this.tileDisplayDuration;
            this.currentTile++;
            if (this.currentTile === this.numberOfTiles) { this.currentTile = 0; }
            const currentColumn = this.currentTile % this.tilesHorizontal;
            texture.offset.x = currentColumn / this.tilesHorizontal;
            const currentRow = Math.floor(this.currentTile / this.tilesHorizontal);
            texture.offset.y = currentRow / this.tilesVertical;
        }
    };
}
// 傳入一個一行裡有 13 幀的序列圖,每張序列圖停留 75ms
const arrowAni = new TextureAnimator(arrowTexture, 13, 1, 13, 75);

在迴圈繪製中更新,保證動畫的執行:

arrowAni.update(delta);

作為引用傳入後,對貼圖的修改會直接體現在使用該貼圖的材質上。

codepen

粒子動畫

Three 中還提供了酷炫的粒子動畫,使用繼承自 Object3D 的 Points 類實現。有了 Points 類我們可以很方便地把一個幾何體渲染成一組粒子,並對它們進行控制。

建立粒子

建立粒子我們首先需要建立粒子的材質,可以使用 PointsMaterial 建立粒子材質。

const texture = new THREE.TextureLoader().load('https://p1.music.126.net/jgzbZtWZhDet2jWzED8BTw==/109951164579600342.png');

material = new THREE.PointsMaterial({
  color: 0xffffff,
  // 對映到材質上的貼圖
  map: texture,
  size: 2,
  // 粒子的大小是否和其與攝像機的距離有關,預設值 true
  sizeAttenuation: true,
});

// 開啟透明度測試,透明度低於0.5的片段會被丟棄,解決貼圖邊緣感問題
material.alphaTest = 0.5;

有了粒子材質後,我們可以應用同一個材質批量建立一組粒子,只需要傳入一個簡單的幾何體。

var particles = new THREE.Points( geometry, material );

如果你傳入的是 BoxGeometry 你可能會得到這樣的一組粒子

還可以根據傳入的 Shape 得到這樣一組粒子

粒子運動

但有趣的粒子絕不是靜止的,而是有活動、有過程的。但如果自己動手實現一個粒子的運動又很複雜,因此希望藉助一些第三方庫實現粒子動畫的緩動過程。

tween.js

tween.js 是一個小型的 JS 庫,我們可以使用它為我們的動畫宣告變化。使用 tween.js 我們不需要關心運動的中間狀態,只需要關注粒子的:

  • 起始位置
  • 最終位置
  • 緩動效果
// srcPosition, targetPosition;
tweens.push(new TWEEN.Tween(srcPosition).easing(TWEEN.Easing.Exponential.In));
// tweens最終位置、緩動時間
tweens[0].to(targetPosition, 5000);
tweens[0].start();、

codepen

其實粒子動畫的場景還有很多,我們可以用他們創造雪花飄散、穿梭效果,本質都是粒子的位置變化。

相機動畫

相機在 3D 空間中充當人的眼睛,因此自然的相機動線可以保證互動的自然流暢。

Controls

Three 提供了一系列相機控制元件來控制場景中的相機軌跡,這些控制元件適用於大部分場景。使用 Controls 開發者可以不再需要去關心使用者互動和相機移動的問題。

活動中也涉及到 OrbitControls 的使用,他提供了環繞物體旋轉、平移和縮放的方法,但由於對使用二維貼圖的情況下,旋轉和縮放都容易穿幫,需要被禁止。

// 建立軌跡
const controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
controls.enabled = !0;
controls.target = new THREE.Vector3();
controls.minDistance = 0;
controls.maxDistance = 2000;
controls.minPolarAngle = Math.PI / 2;
controls.maxPolarAngle = Math.PI / 2;
// 禁用縮放
controls.enableZoom = !1;
// 禁用旋轉
controls.enableRotate !1;
controls.panSpeed = 2;

// 修改控制元件的預設觸控選項,設定為單指雙指都為平移操作
controls.touches = {
    ONE: THREE.TOUCH.PAN,
    TWO: THREE.TOUCH.PAN,
};

this.scene.add(this.camera);

OrbitControl 還允許我們設定阻尼,設定該值表現為數值越接近 1 越難拖動,開啟阻尼後需要我們手動 update 控制元件。

controls.enableDamping = !0;
controls.dampingFactor = 0.2;

檢視原始碼可以看到,阻尼的實現就是依賴滑動時的 offset 乘上一個權重,在通過後續的update不斷為 panOffset 乘上一個權重實現滑動難,撒手後再滑動一點距離。

// this method is exposed, but perhaps it would be better if we can make it private...
this.update = function () {

	// ...

	return function update() {

		// ...

		// 平移

		if ( scope.enableDamping === true ) {
		    // 開啟阻尼後會在原本的位移上乘上一個權重
		    scope.target.addScaledVector( panOffset, scope.dampingFactor );

		} else {

			scope.target.add( panOffset );

		}

		// ...

		if ( scope.enableDamping === true ) {

			sphericalDelta.theta *= ( 1 - scope.dampingFactor );
			sphericalDelta.phi *= ( 1 - scope.dampingFactor );

            // 如果沒有人為操作,隨著時間推移,panOffset會越來越小
			panOffset.multiplyScalar( 1 - scope.dampingFactor );

		} else {

			sphericalDelta.set( 0, 0, 0 );

			panOffset.set( 0, 0, 0 );

		}

		// ...

	};

}();

官方也提供了 Controls 的 例子 供大家參考。

相機動線

如果不使用 Controls,僅僅是相機從一個點移動到另一個點,為了更平滑自然的相機軌跡,推薦使用貝塞爾曲線。

貝塞爾曲線是一個由起點、終點和控制點決定的一條時間相關的變化曲線。這裡以二階貝塞爾曲線為例,實現相機的曲線移動。(三維的點有點難說明白,這裡用二維座標來解釋)

上圖中小黑點的移動軌跡可以看做相機移動的曲線。

從該公式來看,只需要確定 p0、p1 和 p2 三個點,在單位時間下我們可以獲得一條確定的曲線。

但是,換成座標點要怎麼做呢?

// 獲得貝塞爾曲線
function getBezier(p1, p2) {
    // 在指定範圍內隨機生成一個控制點
    const cp = {
        x: p1.x + Math.random() * 100 + 200,
        z: p2.z + Math.random() * 200,
    };

    let t = 0;
    // 貝塞爾曲線公式,根據時間確定點的位置
    return (deltat) => {
        if (t >= 1) return [p2.x, p2.y];
        t += deltat;
        if (t > 1) t = 1;

        const { x: x1, z: z1 } = p1;
        const { x: cx, z: cz } = cp;
        const { x: x2, z: z2 } = p2;
        const x = (1 - t) * (1 - t) * x1 + 2 * t * (1 - t) * cx + t * t * x2;
        const z = (1 - t) * (1 - t) * z1
            + 2 * t * (1 - t) * cz + t * t * z2;

        return [x, z];
    };
}
const bezier = getBezier(p1, p2);

為了從簡,這裡只實現了二維座標的軌跡變化,但三維也是同理。

因為貝塞爾曲線是時間相關曲線,在每一次迴圈渲染中要傳入時間來更新相機位置。

animation() {
    const [x, z] = bezier(clock.getDelta());
    camera.position.x = x;
    camera.position.z = z;
    
    requestAnimationFrame(() => {
            animate();
    });
}

小結

沒趕上 Three 的熱潮,只能趁著活動需求給自己補補課了。在三維空間中,動畫能夠讓空間中的物體更加生動,而相機的移動帶給使用者更強的空間感。

本文介紹了基於 Animation 實現物體的簡單運動、 Texture 實現貼圖動畫以及使用 Points 粒子化的物體動畫方案;基於 Controls 和貝塞爾曲線的相機動畫方案。

對 Three 有興趣的朋友,可以通過 官方文件 來學習,裡面提供的例子覆蓋了大部分場景。

以上是我在活動中涉及到的一些動畫方案,難免會出現理解偏差和表達錯誤,如果有更多的動效方案歡迎一起探討~

參考資料