Three.js 進階之旅:全景漫遊-初階移動相機版

dragonir發表於2023-03-28

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

摘要

3D 全景技術可以實現日常生活中的很多功能需求,比如地圖的街景全景模式、數字展廳、線上看房、社交媒體的全景圖預覽、短影片直播平臺的全景直播等。Three.js 實現全景功能也是十分方便的,當然了目前已經有很多相關內容的文章,我之前就寫過一篇《Three.js 實現3D全景偵探小遊戲》。因此本文內容及此專欄下一篇文章討論的重點不是如何實現 3D 全景圖功能,而是如何一步步優雅實現在多個3D全景中穿梭漫遊,達到如在真實世界中前進後退的視覺效果

全景漫遊系列文章將分為上下兩篇,本篇內容我們先介紹如何透過移動相機的方法來達到場景切換的目的。透過本文的學習,你將學到的知識點包括:在 Three.js 中建立全景圖的幾種方式、在 3D 全景圖中新增互動熱點、利用 Tween.js 實現相機切換動畫、多個全景圖之間的切換等。

效果

本文最終將實現如下的效果,左右控制滑鼠旋轉螢幕可以預覽室內三維全景圖,同時全景圖內有多個互動熱點,它們標識著三維場景內的一些物體,比如沙發 ? 、電視機 ? 等,互動熱點會隨著場景的旋轉而旋轉,點選熱點 ? 可以彈出互動反饋提示框。

點選螢幕上有其他場景名稱的按鈕比如 客廳臥室書房 時,可以從當前場景切換到目標場景全景圖,互動熱點也會同時切換。

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

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

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

原理

我們先來簡單總結下在 Three.js 中實現三維全景功能的有哪些方式:

球體

在球體內新增 HDR 全景照片可以實現三維全景功能,全景照片是一張用球形相機拍攝的圖片,如下圖所示:

const geometry = new THREE.SphereGeometry(500, 60, 40);
geometry.scale(- 1, 1, 1);
const texture = new THREE.TextureLoader().load( 'textures/hdr.jpg');
const material = new THREE.MeshBasicMaterial({ map: texture });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

? 球體全景圖 Three.js 官方示例

立方體

在立方體內新增全景圖貼圖的方式也可以實現三維全景圖功能,此時需要對 HDR 全景照片進行裁切,分割成 6 張來分別對應立方體的 6 個面。

const textures = cubeTextureLoader.load([
  '/textures/px.jpg',
  '/textures/nx.jpg',
  '/textures/py.jpg',
  '/textures/ny.jpg',
  '/textures/pz.jpg',
  '/textures/nz.jpg'
]);

const materials = [];
for ( let i = 0; i < 6; i ++ ) {
  materials.push( new THREE.MeshBasicMaterial( { map: textures[ i ] } ) );
}
const skyBox = new THREE.Mesh( new THREE.BoxGeometry( 1, 1, 1 ), materials );
skyBox.geometry.scale( 1, 1, - 1 );
scene.add( skyBox );

? 立方體全景圖 Three.js 官方示例

環境貼圖

使用環境貼圖也可以實現全景圖功能,像下面這樣載入全景圖片,然後將它賦值給 scene.backgroundscene.environment 即可:

const environmentMap = cubeTextureLoader.load([
    '/textures/px.jpg',
    '/textures/nx.jpg',
    '/textures/py.jpg',
    '/textures/ny.jpg',
    '/textures/pz.jpg',
    '/textures/nz.jpg'
]);
environmentMap.encoding = THREE.sRGBEncoding;
scene.background = environmentMap;
scene.environment = environmentMap;
? 具體原理和實現方式就不詳細介紹了,可檢視我往期的文章《Three.js 進階之旅:多媒體應用-3D Iphone》,環境貼圖段落中有詳細實現介紹。

其他

除了使用 Three.js 自己實現全景圖功能之外,也有一些其他功能完備的全景相簿可以很方便的實現三維全景場景,比如下面幾個就比較不錯,其中後兩個是 GUI 客戶端,可以在客戶端內非常方便的在全景圖上新增互動熱點、實現多個場景的漫遊路徑等,大家感興趣的話都可以試試。

工具

全景圖生成工具

  • 使用球形全景相機拍攝。
  • 使用 Blender 等建模軟體相機 360 度旋轉渲染。

全景圖編輯工具

下面兩個網站提供豐富的三維全景背景照片及將 hdr 圖片裁切成上述需要的 6 張貼圖的能力,大家可以按自己需要下載和編輯。

? HDR全景背景照片下載網站:polyhaven

? HDR立方體材質轉換工具:HDRI-to-CubeMap

實現

現在,我們使用第一種球體 全景圖的方式,來實現示例中介紹的內容。

〇 場景初始化

建立全景圖前先做一些常規三維場景準備工作,由於三維全景圖功能並不會涉及到新的技術點,因此像下面這樣簡單實現就可以。

<canvas class="webgl"></canvas>

在檔案頂部引入以下資源,其中 OrbitControls 用於旋轉全景圖時的鏡頭滑鼠控制;TWEEN 用於建立流程的場景切換動畫,Animations 是使用 TWEEN 來控制攝像機和控制器切換的方法的封裝,可以快速實現鏡頭的絲滑切換;rooms 是自定義的一個陣列,用來儲存多個全景圖的資訊。

import * as THREE from 'three';
import { OrbitControls } from '@/utils/OrbitControls.js';
import { TWEEN } from 'three/examples/jsm/libs/tween.module.min.js';
import Animations from '@/utils/animations';
import { rooms } from '@/views/home/data';

然後初始化渲染器、場景、相機、控制器、頁面縮放適配、頁面重繪動畫等。

const sizes = {
  width: window.innerWidth,
  height: window.innerHeight,
};

// 初始化渲染器
const canvas = document.querySelector('canvas.webgl');
const renderer = new THREE.WebGLRenderer({ canvas });
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

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

// 初始化相機
const camera = new THREE.PerspectiveCamera(65, sizes.width / sizes.height, 0.1, 1000);
camera.position.z = data.cameraZAxis;
scene.add(camera);

// 鏡頭控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);

// 頁面縮放監聽
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 tick = () => {
  controls && controls.update();
  TWEEN && TWEEN.update();
  renderer.render(scene, camera);
  window.requestAnimationFrame(tick);
};
tick();

① 建立一個球體

現在,像下面這樣,我們往場景中新增一個三維球體 ,作為第一個全景圖的載體。其中 THREE.SphereGeometry(radius, segmentsWidth, segmentsHeight, phiStart, phiLength, thetaStart, thetaLength) 接收 7 個引數,我們使用前 3 個引數半徑、經度上的面數切片數、緯度上的切片數即可,數值可按自己的需求自行調整。

const geometry = new THREE.SphereGeometry(16, 256, 256);
const material = new THREE.MeshBasicMaterial({
  color: 0xffffff,
});
const room = new THREE.Mesh(geometry, material);
scene.add(room);

② 建立全景圖

現在我們對球體進行全景圖片貼圖,並將 side 屬性設定為 THREE.DoubleSide 或者 THREE.BackSide 然後透過設定 geometry.scale(1, 1, -1) 將球體內外翻轉,就能得到下面所示的效果。

const geometry = new THREE.SphereGeometry(16, 256, 256);
const material = new THREE.MeshBasicMaterial({
  map: textLoader.load(map),
  side: THREE.DoubleSide,
});
geometry.scale(1, 1, -1);
const room = new THREE.Mesh(geometry, material);

此時,我們透過滑鼠放大球體,進入到球體內部,上下左右旋轉球體,就能觀察到全景效果了。

③ 建立其他場景的全景圖

對於數量較少,簡單的場景我們可以建立多個球體全景圖來實現,這種方式雖然笨重,但是控制多個場景很方便,程式碼也非常容易理解,下篇文章將透過另一種更優雅的方式來實現多個全景圖場景,以適應更加複雜的需求。

我們先對建立球體 全景圖的方法加以封裝,透過 createRoom 方法批次建立多個全景圖場景,它接收的名稱 name、位置 position 以及 貼圖 map 三個引數是透過上述引入的 rooms 數值配置的。

const createRoom = (name, position, map) => {
  const geometry = new THREE.SphereGeometry(16, 256, 256);
  geometry.scale(1, 1, -1);
  const material = new THREE.MeshBasicMaterial({
    map: textLoader.load(map),
    side: THREE.DoubleSide,
  });
  const room = new THREE.Mesh(geometry, material);
  room.name = name;
  room.position.set(position.x, position.y, position.z);
  room.rotation.y = Math.PI / 2;
  scene.add(room);
  return room;
};

// 批次建立
rooms.map((item) => {
  const room = createRoom(item.key, item.position, item.map);
  return room;
});

我們按房間位置的和貼圖的配置,建立如下所示的三個房間客廳、臥室和書房。

④ 限制旋轉角度

根據自己的需求,我們可以對鏡頭控制器 ? 做以下限制,比如開啟轉動慣性、禁止整個場景透過滑鼠右鍵發生平移、設定縮放的最大級別防止暴露出球體、限制垂直方向旋轉等,以增強使用者體驗。

// 轉動慣性
controls.enableDamping = true;
// 禁止平移
controls.enablePan = false;
// 縮放限制
controls.maxDistance = 12;
// 垂直旋轉限制
controls.minPolarAngle = Math.PI / 2;
controls.maxPolarAngle = Math.PI / 2;

⑤ 實現多個場景穿梭漫遊

本文中實現多個場景穿梭漫遊的方法原理:主要是透過移動相機和控制器的中點位置來實現的,我們先用用於生成多個場景的 rooms 數值在頁面上新增一些表示切換房間的按鈕,點選按鈕時拿到需要跳轉的目標場景資訊,然後透過 Animations.animateCamera 方法將像機和控制器從當前位置平滑移動到目標位置

// 點選切換場景
const handleSwitchButtonClick = async (key) => {
  const room = rooms.filter((item) => item.key === key)[0];
  if (data.camera) {
    const x = room.position.x;
    const y = room.position.y;
    const z = room.position.z;
    Animations.animateCamera(data.camera, data.controls, { x, y, z: data.cameraZAxis }, { x, y, z }, 1600, () => {});
    data.controls.update();
  }
};

其中 Animations.animateCamera 方法是使用 TWEEN.js 封裝的一個移動相機 ? 和控制器 ? 的方法,使用它可以實現絲滑的鏡頭補間動畫,不僅可以像本文中這樣來實現多個場景的切換,還可以實現像鏡頭從遠處拉近、點選互動點後鏡頭聚焦放大到某個區域性鏡頭場景巡航等效果。完整程式碼可以檢視本篇文章的示例程式碼:

animateCamera: (camera, controls, newP, newT, time = 2000, callBack) => {
  const tween = new TWEEN.Tween({
    x1: camera.position.x, // 相機x
    y1: camera.position.y, // 相機y
    z1: camera.position.z, // 相機z
    x2: controls.target.x, // 控制點的中心點x
    y2: controls.target.y, // 控制點的中心點y
    z2: controls.target.z, // 控制點的中心點z
  });
  tween.to(
    {
      x1: newP.x,
      y1: newP.y,
      z1: newP.z,
      x2: newT.x,
      y2: newT.y,
      z2: newT.z,
    },
    time,
  );
  // ...
}

⑥ 新增互動點

場景漫遊穿梭的功能已經實現了,現在我們來在全景場景中新增一些互動熱點 ,用於實現場景物體標註和滑鼠點選互動,比如我們在這個示例中,在客廳中新增了 電視機?沙發?冰箱❄️ 等互動點,我們可以現在建立場景的陣列中新增這些互動點的資訊 interactivePoints,以方便批次建立,根據自己的需求我們可以新增一些可選的配置引數,本文中的引數含義分別是:

  • key:唯一識別符號。
  • value:顯示名稱。
  • description:描述文案。
  • cover:配圖。
  • position:在三維空間中的位置。
const rooms = [
  {
    name: '客廳',
    key: 'living-room',
    map: new URL('@/assets/images/map/map_living_room.jpg', import.meta.url).href,
    position: new Vector3(0, 0, 0),
    interactivePoints: [
      {
        key: 'tv',
        value: '電視機',
        description: '智慧電視',
        cover: new URL('@/assets/images/home/cover_living_room_tv.png', import.meta.url).href,
        position: new Vector3(-6, 2, -8),
      },
      // ...
    ],
  },

然後在頁面上利用 rooms 陣列的 interactivePoints 來批次建立互動點的 DOM 節點:

<div
  class="point"
  v-for="(point, index) in interactivePoints"
  :key="index"
  :class="[`point-${index}`, `point-${point.key}`]"
  @click="handleReactivePointClick(point)"
  v-show="point.room === data.currentRoom"
>
  <div class="label" :class="[`label-${index}`, `label-${point.key}`]">
    <label class="label-tips">
      <div class="cover">
        <i
          class="icon"
          :style="{
            background: `url(${point.cover}) no-repeat center`,
            'background-size': 'contain',
          }"
        ></i>
      </div>
      <div class="info">
        <p class="p1">{{ point.value }}</p>
        <p class="p2">{{ point.description }}</p>
      </div>
    </label>
  </div>
</div>

用樣式表把互動點設定成自己喜歡的樣式 ? ,需要注意的一點是,互動點 ? 初始的樣式中設定了 transform: scale(0, 0), 即它的寬高都為 0,是隱藏看不見的,這樣設定的目的是為了實現只有互動點出現在相機可視區域時才顯示在場景中,其他轉動到相機背面時應該隱藏掉。當互動點被新增 .visible 類時,互動點變為顯示狀態。本示例中還使用互動點內 .label::before.label::after等偽元素和子元素新增了一些波紋擴散動畫及其其他文案資訊等。

.point
  position: fixed
  top: 50%
  left: 50%
  .label
    position: absolute
    &::before, &::after
      display inline-block
      content ''
    &::before
      animation: bounce-wave 1.5s infinite
    &::after
      animation: bounce-wave 1.5s -0.4s infinite
    .label-tips
      height 88px
      width 200px
      position absolute
  &.visible .label
    transform: scale(1, 1)
? 隱藏顯示的互動也可以透過 display:nonevisibility:hidden、及使用 js 變數控制元素隱藏顯示等方式來實現。

建立完互動點 ? 元素之後,我們還需要在頁面重繪方法 tick() 中像下面這樣新增一個方法,來將互動點顯示在三維場景中,並根據與相機的關係來控制每個互動點的顯示與隱藏,原理是使用 THREE.Raycaster 來檢測元素是否被遮擋:

const raycaster = new THREE.Raycaster();

const tick = () => {
  for (const point of _points) {
    // 獲取2D螢幕位置
    const screenPosition = point.position.clone();
    const pos = screenPosition.project(camera);
    raycaster.setFromCamera(screenPosition, camera);
    const intersects = raycaster.intersectObjects(scene.children, true);
    if (intersects.length === 0) {
      // 未找到相交點,顯示
      point.element.classList.add('visible');
    } else {
      // 獲取相交點的距離和點的距離
      const intersectionDistance = intersects[0].distance;
      const pointDistance = point.position.distanceTo(camera.position);
      // 相交點距離比點距離近,隱藏;相交點距離比點距離遠,顯示
      intersectionDistance < pointDistance
        ? point.element.classList.remove('visible')
        : point.element.classList.add('visible');
    }
    pos.z > 1
      ? point.element.classList.remove('visible')
      : point.element.classList.add('visible');
    const translateX = screenPosition.x * sizes.width * 0.5;
    const translateY = -screenPosition.y * sizes.height * 0.5;
    point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`;
  }
  // ...
};
? 關於使用 Raycaster 來檢測元素是否被遮擋的詳細介紹,可以看看我的這篇文章《Three.js 打造繽紛夏日3D夢中情島》

⑦ 頁面最佳化和載入進度管理

最後,因為建立多個三維全景圖場景需要載入很多張圖片,而且全景圖的圖片一般比較大,我們可以預先載入完所有圖片後再進行渲染,本文使用的是自己新增的一個預載入方法,也可以使用像 preload.js 等其他庫來預載入圖片。除了載入進度顯示之外,現實開發場景中應該還有很多個性化的需求,比如可以在點選互動點的時候彈出一個詳細彈窗、點選電視的時候開始播放一段影片、點選沙發的時候鏡頭聚焦放大到沙發、點選開關的時候變為夜間模式……這些互動的原理和本文中的互動點是差不多的 ?

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

總結

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

  • Three.js 中實現全景圖的原理和多種實現方式。
  • 與全景圖相關的生成工具、編輯工具的使用。
  • 建立多個全景圖並實現多個場景間的漫遊穿梭功能。
  • 在三維全景圖中新增互動熱點。

本文到這裡就結束了,本文中透過移動相機鏡頭和控制的方法來實現幾個全景圖之間漫遊穿梭效果還是不錯的,但是它的缺點也是很明顯的,就是當全景場景數量特別多時,就需要建立非常多的球體,此時計算出每個場景的位置非常困難,並且會造成頁面效能耗損問題,因此需要進行最佳化。下篇文章將會介紹另一種更加優雅的方式來實現全景圖之間的漫遊功能,過渡動畫也會更加流暢絲滑。

想了解其他前端知識或其他未在本文中詳細描述的Web 3D開發技術相關知識,可閱讀我往期的文章。如果有疑問可以在評論中留言,如果覺得文章對你有幫助,不要忘了一鍵三連哦 ?

附錄

參考

相關文章