Three.js 進階之旅:新春特典-Rabbit craft go ?

dragonir發表於2023-01-22

宣告:本文涉及圖文和模型素材僅用於個人學習、研究和欣賞,請勿二次修改、非法傳播、轉載、出版、商用、及進行其他獲利行為。

摘要

兔年到了,祝大家身體健,康萬事順利。本文內容作為兔年新春紀念頁面,將使用 Three.js 及 其他前端開發知識,建立一個以兔子為主題的 3D 簡單的趣味頁面 Rabbit craft go。本文內容包括使用純程式碼建立三維浮島、小河、樹木、兔子、胡蘿蔔以及兔子的運動互動、浮島的動畫效果等。本文包含的知識點相對比較簡單,主要包括 使用 Three.js 網格立方體搭建三維卡通場景、鍵盤事件的監聽與三維場景動畫的結合等,如果仔細閱讀並實踐過本專欄《Three.js 進階之旅》的話,非常容易掌握。

? 兔子造型來源於 Three.js開源論壇,頁面整體造型靈感來源於《我的世界》,頁面名稱靈感來源於遊戲《Lara Craft Go》。

效果

我們先來看看實現效果,頁面載入完成後是一個遊戲操作提示介面,可以透過鍵盤 空格鍵WASD 或方向鍵操作小兔子運動。點選開始按鈕後,遊戲提示介面消失,可以看到倒三角造型的天空浮島及浮島上方的樹木 ?、河流 、橋 ?、胡蘿蔔 ?、兔子 ? 等元素,接著攝像機鏡頭 ? 自動拉近並聚焦到兔子上。

按照操作提示介面的按鍵,可以操作兔子進行前進、轉向、跳躍等運動,當兔子的運動位置觸碰到胡蘿蔔時,胡蘿蔔會消失同時兔子會進行跳躍運動。當兔子運動到小河或者超出浮島範圍時,兔子則會墜落到下方。

開啟以下連結,線上預覽效果,大屏訪問效果更佳。

本專欄系列程式碼託管在 Github 倉庫【threejs-odessey】後續所有目錄也都將在此倉庫中更新

? 程式碼倉庫地址:git@github.com:dragonir/threejs-odessey.git

實現

文章篇幅有限,因此刪減了三維模型的位置資訊等細節調整程式碼,只提供構建三維模型的整體思路邏輯,想了解該部分內容的詳細介紹可以閱讀本專欄前幾篇文章及閱讀本文配套原始碼。現在,我們來看看整個頁面的實現詳細步驟:

頁面結構

Rabbit Craft Go 頁面的整體結構如下,其中 canvas.webgl 是用於渲染場景的容器、剩餘標籤都是一些裝飾元素或提示語。

<canvas class="webgl"></canvas>
<div class="mask" id="mask">
  <div class="box">
    <div class="keyboard">
      <div class="row"><span class="key">W/↑</span></div>
      <div class="row"><span class="key">A/←</span><span class="key">S/↓</span><span class="key">D/→</span></div>
      <div class="row"><span class="key space">space</span></div>
    </div>
    <p class="tips"><b>W</b>: 行走&emsp;<b>S</b>: 停止&emsp;<b>A</b>: 向左轉&emsp;<b>D</b>: 向右轉&emsp;<b>空格鍵</b>: 跳躍</p>
    <p class="start"><button class="button" id="start_button">開始</button></p>
  </div>
</div>
<a class='github' href='https://github.com/dragonir/threejs-odessey' target='_blank' rel='noreferrer'>
  <span class='author'>three.js odessey</span>
</a>
<h1 class="title">RABBIT CRAFT GO!</h1>
<div class="banner"><i></i></div>

場景初始化

場景初始化過程中,我們引入必需的開發資源,並初始化渲染場景、相機、控制器、光照、頁面縮放適配等。其中外部資源的引入,其中 OrbitControls 用於頁面鏡頭縮放及移動控制;TWEENAnimations 用於生成鏡頭補間動畫,也就是剛開始時浮島由遠及近的鏡頭切換動畫中效果;IslandCarrotRabbitWaterfall 等是用來構建三維世界的類。為了使場景更加卡通化,使用了 THREE.sRGBEncoding 渲染效果。場景中新增了兩種光源,其中 THREE.DirectionalLight 用來生成陰影效果。

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";
import Animations from './environment/animation';
import Island from './environment/island';
import Carrot from './environment/carrot';
import Rabbit from './environment/rabbit';
import Waterfall from './environment/waterfall';

// 初始化渲染器
const canvas = document.querySelector('canvas.webgl');
const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
  antialias: true,
  alpha: true
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.shadowMap.enabled = true;
renderer.shadowMap.needsUpdate = true;

// 初始化場景
const scene = new THREE.Scene();

// 初始化相機
const camera = new THREE.PerspectiveCamera(60, sizes.width / sizes.height, 1, 5000)
camera.position.set(-2000, -250, 2000);

// 鏡頭控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.enablePan = false;
controls.dampingFactor = 0.15;

// 頁面縮放事件監聽
window.addEventListener('resize', () => {
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;
  // 更新渲染
  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  // 更新相機
  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();
});

// 光照
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
scene.add(directionalLight);

建立浮島

如以下 ? 兩幅圖所示,整個浮島造型是一個四稜椎,整體分為四部分,頂部是由地面和河流構成的四方體、底部三塊是倒置的三角。生成這些三維模型的其實也並沒有多少技巧,就像搭積木一樣使用 Three.js 提供的立方體網格透過計算拼接到一起即可。類 Island 包含一個方法 generate 用於建立上述三維模型,並將所建立模型新增到三維分組 floorMesh 中用於外部呼叫,其中稜柱部分是透過 CylinderBufferGeometry 來實現的。

export default class Island {
  constructor() {
    this.floorMesh = new THREE.Group();
    this.generate();
  }

  generate() {
    // 左側地面
    const leftFieldMat = new THREE.MeshToonMaterial({
      color: 0x015521d,
      side: THREE.DoubleSide,
    });
    const leftFieldGeom = new THREE.BoxBufferGeometry(800, 30, 1800);
    this.leftFieldMesh = new THREE.Mesh(leftFieldGeom, leftFieldMat);
    // 右側地面
    this.rightFieldMesh = this.leftFieldMesh.clone();
    const mapCapMat = new THREE.MeshMatcapMaterial({
      matcap: new THREE.TextureLoader().load('./images/matcap.png'),
      side: THREE.DoubleSide
    })
    // 頂部稜柱
    const topFieldGeom = new THREE.CylinderBufferGeometry(1200, 900, 200, 4, 4);
    this.topFieldMesh = new THREE.Mesh(topFieldGeom, mapCapMat);
    // 中間稜柱
    const middleFieldGeom = new THREE.CylinderBufferGeometry(850, 600, 200, 4, 4);
    this.middleFieldMesh = new THREE.Mesh(middleFieldGeom, mapCapMat);
    // 底部稜錐
    const bottomFieldGeom = new THREE.ConeBufferGeometry(550, 400, 4);
    this.bottomFieldMesh = new THREE.Mesh(bottomFieldGeom, mapCapMat);
    // 河面
    const strGroundMat = new THREE.MeshLambertMaterial({
      color: 0x75bd2d,
      side: THREE.DoubleSide,
    });
    const strCroundGeom = new THREE.BoxBufferGeometry(205, 10, 1800);
    this.strGroundMesh = new THREE.Mesh(strCroundGeom, strGroundMat);

    // 小河
    const streamMat = new THREE.MeshLambertMaterial({
      color: 0x0941ba,
      side: THREE.DoubleSide,
    });
    const streamGeom = new THREE.BoxBufferGeometry(200, 16, 1800);
    this.streamMesh = new THREE.Mesh(streamGeom, streamMat);
    // ...
  }
};

浮島俯檢視是一個正方形

浮島側檢視是一個倒三角形

建立水流

接下來,我們為河流新增一個小瀑布,使場景動起來。流動的瀑布三維水滴 ? 滴落效果的是透過建立多個限定範圍內隨機位置的 THREE.BoxBufferGeometry 來實現水滴模型,然後透過水滴的顯示隱藏動畫實現視覺上的水滴墜落效果。Waterfall 類用於建立單個水滴,它為水滴初始化隨機位置和速度,並提供一個 update 方法用來更新它們。

export default class Waterfall {
  constructor (scene) {
    this.scene = scene;
    this.drop = null;
    this.generate();
  }
  generate () {
    this.geometry = new THREE.BoxBufferGeometry(15, 50, 5);
    this.material = new THREE.MeshLambertMaterial({ color: 0x0941ba });
    this.drop = new THREE.Mesh(this.geometry, this.material);
    this.drop.position.set((Math.random() - 0.5) * 200, -50, 900 + Math.random(1, 50) * 10);
    this.scene.add(this.drop);
    this.speed = 0;
    this.lifespan = Math.random() * 50 + 50;
    this.update = function() {
      this.speed += 0.07;
      this.lifespan--;
      this.drop.position.x += (5 - this.drop.position.x) / 70;
      this.drop.position.y -= this.speed;
    };
  }
};

完成水滴建立後,不要忘了需要在頁面重繪動畫 tick 方法中像這樣更新已建立的水滴陣列 drops,使其看起來生成向下流動墜落的效果。

for (var i = 0; i < drops.length; i++) {
  drops[i].update();
  if (drops[i].lifespan < 0) {
    scene.remove(scene.getObjectById(drops[i].drop.id));
    drops.splice(i, 1);
  }
}

建立橋

在河流上方新增一個小木橋 ?,這樣小兔子就可以透過木橋在小河兩邊移動了。 類 Bridge 透過 generate 方法建立一個小木橋,並透過三維模型組 bridgeMesh 將其匯出,我們可以在上面建立的 Island 類中使用它,將其新增到三維場景中。

export default class Bridge {
  constructor() {
    this.bridgeMesh = new THREE.Group();
    this.generate();
  }
  generate() {
    var woodMat = new THREE.MeshLambertMaterial({
      color: 0x543b14,
      side: THREE.DoubleSide
    });
    // 木頭
    for (var i = 0; i < 15; i++) {
      var blockGeom = new THREE.BoxBufferGeometry(10, 3, 70);
      var block = new THREE.Mesh(blockGeom, woodMat);
      this.bridgeMesh.add(block);
    }
    // 橋尾
    var geometry_rail_v = new THREE.BoxBufferGeometry(3, 20, 3);
    var rail_1 = new THREE.Mesh(geometry_rail_v, woodMat);
    var rail_2 = new THREE.Mesh(geometry_rail_v, woodMat);
    var rail_3 = new THREE.Mesh(geometry_rail_v, woodMat);
    var rail_4 = new THREE.Mesh(geometry_rail_v, woodMat);
    // ...
  }
}

建立樹

從預覽動圖和頁面可以看到,浮島上共有兩種樹 ?,綠色的高樹和粉紅色的矮樹,樹的實現也非常簡單,是使用了兩個 BoxBufferGeometry 拼接到一起。類 TreeLeafTree 分別用於生成這兩種樹木,接收引數 (x, y, z) 分別表示樹木在場景中的位置資訊。我們可以在 Island 輔導上新增一些樹木,構成浮島上的一片小森林。

export default class Tree {
  constructor(x, y, z) {
    this.x = x;
    this.y = y;
    this.z = z;
    this.treeMesh = new THREE.Group();
    this.generate();
  }
  generate() {
    // 樹幹
    var trunkMat = new THREE.MeshLambertMaterial({
      color: 0x543b14,
      side: THREE.DoubleSide
    });
    var trunkGeom = new THREE.BoxBufferGeometry(20, 200, 20);
    this.trunkMesh = new THREE.Mesh(trunkGeom, trunkMat);
    // 樹葉
    var leavesMat = new THREE.MeshLambertMaterial({
      color: 0x016316,
      side: THREE.DoubleSide
    });
    var leavesGeom = new THREE.BoxBufferGeometry(80, 400, 80);
    this.leavesMesh = new THREE.Mesh(leavesGeom, leavesMat);
    this.treeMesh.add(this.trunkMesh);
    this.treeMesh.add(this.leavesMesh);
    this.treeMesh.position.set(this.x, this.y, this.z);
    // ...
  }
}

矮樹

export default class LeafTree {
  constructor(x, y, z) {
    this.x = x;
    this.y = y;
    this.z = z;
    this.treeMesh = new THREE.Group();
    this.generate();
  }
  generate() {
    // ...
  }
}

建立胡蘿蔔

接著,在地面上新增一些胡蘿蔔 ?。胡蘿蔔身體部分是透過四稜柱 CylinderBufferGeometry 實現的,然後透過 BoxBufferGeometry 立方體來實現胡蘿蔔的兩片葉子。場景中可以透過 Carrot 類來新增胡蘿蔔,本頁面示例中是透過迴圈呼叫新增了 20 個隨機位置的胡蘿蔔。

export default class Carrot {
  constructor() {
    this.carrotMesh = new THREE.Group();
    this.generate();
  }
  generate() {
    const carrotMat = new THREE.MeshLambertMaterial({
      color: 0xd9721e
    });
    const leafMat = new THREE.MeshLambertMaterial({
      color: 0x339e33
    });
    // 身體
    const bodyGeom = new THREE.CylinderBufferGeometry(5, 3, 12, 4, 1);
    this.body = new THREE.Mesh(bodyGeom, carrotMat);
    // 葉子
    const leafGeom = new THREE.BoxBufferGeometry(5, 10, 1, 1);
    this.leaf1 = new THREE.Mesh(leafGeom, leafMat);
    this.leaf2 = this.leaf1.clone();
    // ...
    this.carrotMesh.add(this.body);
    this.carrotMesh.add(this.leaf1);
    this.carrotMesh.add(this.leaf2);
  }
};
for (let i = 0; i < 20; i++) {
  carrot[i] = new Carrot();
  scene.add(carrot[i].carrotMesh);
  carrot[i].carrotMesh.position.set(-170 * Math.random() * 3 - 300, -12, 1400 * Math.random() * 1.2 - 900);
}

建立兔子

最後,來建立頁面的主角兔子 ?。兔子全部都是由立方體 BoxBufferGeometry 搭建而成的,整體可以分解為頭、眼睛、耳朵、鼻子、嘴、鬍鬚、身體、尾巴、四肢等構成,構建兔子時的核心要素就是各個立方體位置和縮放比例的調整,需要具備一定的審美能力,當然本例中使用的兔子是在 Three.js 社群開原始碼的基礎上改造的 ?

完成兔子的整體外形之後,我們透過 gsap 給兔子新增一些運動動畫效果和方法以供外部呼叫,其中 blink() 方法用於眨眼、jump() 方法用於原地跳躍、nod() 方法用於點頭、run() 方法用於奔跑、fall() 方法用於邊界檢測時檢測到超出運動範圍時使兔子墜落效果等。完成 Rabbit 類後,我們就可以在場景中初始化小兔子。

import { TweenMax, Power0, Power1, Power4, Elastic, Back } from 'gsap';

export default class Rabbit {
  constructor() {
    this.bodyInitPositions = [];
    this.runningCycle = 0;
    this.rabbitMesh = new THREE.Group();
    this.bodyMesh = new THREE.Group();
    this.headMesh = new THREE.Group();
    this.generate();
  }
  generate() {
    var bodyMat = new THREE.MeshLambertMaterial({
      color: 0x5c6363
    });
    var tailMat = new THREE.MeshLambertMaterial({
      color: 0xc2bebe
    });
    var nouseMat = new THREE.MeshLambertMaterial({
      color: 0xed716d
    });
    // ...
    var pawMat = new THREE.MeshLambertMaterial({
      color: 0xbf6970
    });
    var bodyGeom = new THREE.BoxBufferGeometry(50, 50, 42, 1);
    var headGeom = new THREE.BoxBufferGeometry(44, 44, 54, 1);
    var earGeom = new THREE.BoxBufferGeometry(5, 60, 10, 1);
    var eyeGeom = new THREE.BoxBufferGeometry(20, 20, 8, 1);
    var irisGeom = new THREE.BoxBufferGeometry(8, 8, 8, 1);
    var mouthGeom = new THREE.BoxBufferGeometry(8, 16, 4, 1);
    var mustacheGeom = new THREE.BoxBufferGeometry(0.5, 1, 22, 1);
    var spotGeom = new THREE.BoxBufferGeometry(1, 1, 1, 1);
    var legGeom = new THREE.BoxBufferGeometry(33, 33, 10, 1);
    var pawGeom = new THREE.BoxBufferGeometry(45, 10, 10, 1);
    var pawFGeom = new THREE.BoxBufferGeometry(20, 20, 20, 1);
    var tailGeom = new THREE.BoxBufferGeometry(20, 20, 20, 1);
    var nouseGeom = new THREE.BoxBufferGeometry(20, 20, 15, 1);
    var tailGeom = new THREE.BoxBufferGeometry(23, 23, 23, 1);
    this.body = new THREE.Mesh(bodyGeom, bodyMat);
    this.bodyMesh.add(this.body);
    this.head = new THREE.Mesh(headGeom, bodyMat);
    this.bodyMesh.add(this.legL);
    this.headMesh.add(this.earR);
    this.rabbitMesh.add(this.bodyMesh);
    this.rabbitMesh.add(this.headMesh);
    // ...
  }
  blink() {
    var sp = 0.5 + Math.random();
    if (Math.random() > 0.2)
      TweenMax.to([this.eyeR.scale, this.eyeL.scale], sp / 8, {
        y: 0,
        ease: Power1.easeInOut,
        yoyo: true,
        repeat: 3
      });
  }
  // 跳躍
  jump() {
    var speed = 10;
    var totalSpeed = 10 / speed;
    var jumpHeight = 150;
    TweenMax.to(this.earL.rotation, totalSpeed / 2, {
      z: "+=.3",
      ease: Back.easeOut,
      yoyo: true,
      repeat: 1
    });
    TweenMax.to(this.earR.rotation, totalSpeed / 2, {
      z: "-=.3",
      ease: Back.easeOut,
      yoyo: true,
      repeat: 1
    });
    // ...
  }
  // 點頭
  nod() {}
  // 奔跑
  run() {}
  // 移動
  move() {}
  // 墜落
  fall() {}
  // 動作銷燬
  killNod() {}
  killJump() {}
  killMove() {}
}

將兔子新增到場景中。

新增動畫和操作

為了使兔子可以運動和可互動,我們透過監聽鍵盤按鍵的方式來呼叫兔子類內建的對應動畫方法,兔子的方向轉動可以透過修改兔子的旋轉屬性 rotation 來實現。

// 兔子控制
const rabbitControl = {
  tureLeft: () => {
    rabbit && (rabbit.rabbitMesh.rotation.y -= Math.PI / 2);
  },
  turnRight: () => {
    rabbit && (rabbit.rabbitMesh.rotation.y += Math.PI / 2);
  },
  stopMove: () => {
    rabbitMoving = false;
    rabbit.killMove();
    rabbit.nod();
  },
}

// 鍵盤監聽
document.addEventListener('keydown', e => {
  if (e && e.keyCode) {
    switch(e.keyCode) {
      // 左
      case 65:
      case 37:
        rabbitControl.tureLeft();
        break;
      // 右
      case 68:
      case 39:
        rabbitControl.turnRight();
        break;
      // 前
      case 87:
      case 38:
        rabbitMoving = true;
        break;
      // 空格鍵
      case 32:
        !rabbitJumping && rabbit.jump() && (rabbitJumping = true);
        break;
      default:
        break;
    }
  }
});

document.addEventListener('keyup', e => {
  if (e && e.keyCode) {
    switch(e.keyCode) {
      case 83:
      case 40:
      case 87:
      case 38:
        rabbitMoving = false;
        rabbit.killMove();
        rabbit.nod();
        break;
      case 32:
        setTimeout(() => {
          rabbitJumping = false;
        }, 800);
        break;
    }
  }
});

為了使場景更加真實和趣味,我們可以新增一些邊界檢測方法,當兔子位置處於非可運動區域如小河、浮島之外等區域時,可以呼叫兔子的 fall(),方法使其墜落。當檢測到兔子的位置和胡蘿蔔的位置重疊時,給兔子新增了一個 jump() 跳躍動作並使檢測到的這個胡蘿蔔從場景中移除。

const checkCollision = () => {
  for (let i = 0; i < 20; i++) {
    let rabbCarr = rabbit.rabbitMesh.position.clone().sub(carrot[i].carrotMesh.position.clone());
    if (rabbCarr.length() <= 20) {
      rabbit.jump();
      scene.remove(carrot[i].carrotMesh);
      rabbCarr = null;
    }
  }
  // 檢查是否是地面的邊界
  var rabbFloor = island.floorMesh.position.clone().sub(rabbit.rabbitMesh.position.clone());
  if (
    rabbFloor.x <= -900 ||
    rabbFloor.x >= 900 ||
    rabbFloor.z <= -900 ||
    rabbFloor.z >= 900
  ) {
    rabbit.fall();
  }
  // 小河檢測
  var rabbStream = rabbit.rabbitMesh.position.clone().sub(island.streamMesh.position.clone());
  if (
    (rabbStream.x >= -97 &&
      rabbStream.x <= 97 &&
      rabbStream.z >= -900 &&
      rabbStream.z <= 688) ||
    (rabbStream.x >= -97 && rabbStream.x <= 97 && rabbStream.z >= 712)
  ) {
    rabbit.fall();
  }
}

頁面裝飾

最後,我們來製作一個其實頁面,中間部分是鍵盤操作說明,底部是一些裝飾文案圖片,操作提示下方是一個開始按鈕,我們給這個按鈕新增一個透過 TWEEN.js 實現的鏡頭補間動畫效果,當點選按鈕時,頁面首先顯示的是倒置三角造型的浮島,然後鏡頭慢慢方法拉近,顯示出兔子運動的區域。本頁面為了使其看起來更加符合遊戲主題,標題文案使用了一種畫素化的字型

const startButton = document.getElementById('start_button');
const mask = document.getElementById('mask');
startButton.addEventListener('click', () => {
  mask.style.display = 'none';
  Animations.animateCamera(camera, controls, { x: 50, y: 120, z: 1000 }, { x: 0, y: 0, z: 0 }, 3600, () => {});
});

? 原始碼地址:https://github.com/dragonir/threejs-odessey

總結

本文中主要包含的知識點包括:

  • 使用 Three.js 網格立方體搭建三維卡通場景
  • 鍵盤事件的監聽與三維場景動畫的結合
想了解其他前端知識或其他未在本文中詳細描述的Web 3D開發技術相關知識,可閱讀我往期的文章。如果有疑問可以在評論中留言,如果覺得文章對你有幫助,不要忘了一鍵三連哦 ?

附錄

參考

相關文章