Three.js 进阶之旅:全景漫游-初阶移动相机版

  • Post category:JavaScript

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

摘要

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开发技术相关知识,可阅读我往期的文章。如果有疑问可以在评论中留言,如果觉得文章对你有帮助,不要忘了一键三连哦 ?

附录

参考

本文作者:dragonir 本文地址:https://www.cnblogs.com/dragonir/p/17263717.html