在Three.js中实现全景看图功能

全景看图是一个比较有趣的功能,全景看图让我们有一种身临场景的感觉,给用户一种更直观的体验。实现全景看图通过CSS3D即可实现,但是这里我分享关于Three.js的全景看图,因为Three.js可以实现更丰富的交互。

前言

虽然例子很简单,但也需要您掌握基本的three.js知识点,例如场景、模型等基础。

感兴趣的可以先访问在线实例看看。

开始

废话不多说,现在就来开始进行代码的实践。

准备工作

首先实现全景看图功能,就得先有全景图,我这里的全景图通过在polyhaven这个网站里面获取的,你可以选择你喜欢的图片进行下载。

设计思路

我们设计全景看图的思路就两个步骤:

  1. 创建一个球体,将全景图作为纹理添加到球体上;
  2. 把摄像机放置入球体内部。 通过这两个步骤,就能很简单的实现一个全景预览的功能。

代码实现

球体类实现

ts 复制代码
import { EventEmitter } from 'events';
import { DataTexture, DoubleSide, Mesh, MeshBasicMaterial, Scene, SphereGeometry } from 'three';
import { Game } from '../index';

export class SphereInstance extends EventEmitter {
  game: Game;
  scene: Scene;
  mesh!: Mesh<SphereGeometry, MeshBasicMaterial>;
  geometry: SphereGeometry = new SphereGeometry(2, 32, 32);
  constructor() {
    super();
    this.game = Game.getInstance();
    this.scene = this.game.gameScene.scene;
  }

  init(texture: DataTexture) {
    const material = new MeshBasicMaterial({
      map: texture,
      side: DoubleSide
    });
    this.mesh = new Mesh(this.geometry, material);
    this.scene.add(this.mesh);
    return this.mesh;
  }

  update() { }
}

上面,我创建一个球体类,通过init方法来添加不同纹理的球体网格。

接着,我们再对球体网格进行初始化:

ts 复制代码
  initSphere() {
    this.sphere = new SphereInstance();
    this.addScene('scene1', 'acoustical_shell_1k');
    this.addScene('scene2', 'rural_asphalt_road_1k');
    this.addScene('scene3', 'kloofendal_43d_clear_1k');
    this.addScene('scene4', 'resting_place_1k');
    this.scene2.position.set(2, 0, -4);
    this.scene3.position.set(4, 0, -8);
    this.scene4.position.set(-4, 0, -8);

    this.activeScene = this.scene1;
  }

  addScene(sceneName: SceneName, textureName: string) {
    const acoustical_shell_1k = this.game.resource.getHDR(textureName) as DataTexture;
    this[sceneName] = this.sphere.init(acoustical_shell_1k);
  }

这里,我通过定义的addScene方法创建纹理所对应的所有球体网格,并且初始化不同的位置信息。

当然,你可以可以选择先加载一个纹理,等场景切换时或者其他时候进行动态加载。

完成这些步骤后,你在浏览器中应该会看到类似下面的结果:

也就是我们的纹理贴图,但是目前仅仅只有一张图的功能,不能切换视角,更不能切换场景。

视角切换

视角切换的方案挺多,可以用OrbitControls来实现,但是我这里希望的效果是相机原地不动看四周,但OrbitControls这种不能实现这种效果(或者我没有发现用法)。

然后又找了FirstPersonalControls来测试,发现效果也不是我希望的。

控件实现

没有找到对应的插件,那就没办法只有自己来手动实现自己想要的效果了,现在对控件功能进行分析:

  1. 相机原地不动,通过滑动鼠标位置进行视角切换;
  2. 横向滑动时,摄像机Y轴进行旋转;
  3. 纵向滑动时,摄像机X轴进行旋转;
  4. 旋转的顺序是先Y轴再进行X轴旋转。

明白以上几点后,我们就可以开始进行控件的代码设计:

ts 复制代码
import EventEmitter from "events";
import { Euler, PerspectiveCamera, Quaternion, Vector2, Vector3 } from "three";
import { Game } from "..";

export class FirstPersonalControls extends EventEmitter {
  game: Game;
  camera: PerspectiveCamera;
  domElement: HTMLCanvasElement;

  mouse: Vector2 = new Vector2(); // 记录点击鼠标的当前位置和上一次的偏移值
  offset: Vector2 = new Vector2(); // 记录按住鼠标后的的下一次偏移值
  isActive: boolean = false;

  currentPosition: Vector3 = new Vector3();

  xAxis: Vector3 = new Vector3(1, 0, 0);
  yAxis: Vector3 = new Vector3(0, 1, 0);

  quaternionX: Quaternion = new Quaternion();
  quaternionY: Quaternion = new Quaternion();
  constructor(camera: PerspectiveCamera, domElement: HTMLCanvasElement) {
    super();
    this.camera = camera;
    this.camera.rotation.order = 'YXZ';
    this.domElement = domElement;
    this.game = Game.instance;

    this.initPCEvents();
  }

  initPCEvents() {
    this.domElement.addEventListener('mousedown', evt => {
      const { offsetX, offsetY } = evt;
      this.onStart(offsetX, offsetY);
    });

    this.domElement.addEventListener('mousemove', evt => {
      const { offsetX, offsetY } = evt;
      this.offset.x = offsetX;
      this.offset.y = offsetY;
    });

    document.addEventListener('mouseup', () => {
      this.isActive = false;
    });
  }

  onStart(offsetX: number, offsetY: number) {
    this.mouse.x = offsetX;
    this.mouse.y = offsetY;
    this.offset.copy(this.mouse);
    this.isActive = true;
  }

  onMove() {
    const disX = this.offset.x - this.mouse.x;
    const disY = this.offset.y - this.mouse.y;
    this.mouse.x = this.offset.x;
    this.mouse.y = this.offset.y;

    // 两次移动的像素间距
    let x = -disX / this.game.width;
    let y = -disY / this.game.height;
    const speed = 5;
    // X轴的旋转速度
    let xSpeed = speed * y;
    // 现在X轴最大幅度
    const maxAngle = Math.PI * 0.5;
    // 获取当前的旋转幅度欧拉值
    const euler = new Euler().setFromQuaternion(this.camera.quaternion, 'YXZ');
    if (
      (euler.x + xSpeed > maxAngle) ||
      (euler.x + xSpeed < -maxAngle)
    ) {
      xSpeed = 0; // 当超过90度后不再进行X轴旋转
    }

    this.quaternionX.setFromAxisAngle(this.xAxis, xSpeed);  // 进行X轴的四元素赋值
    this.quaternionY.setFromAxisAngle(this.yAxis, speed * x); // 进行Y轴的四元素赋值

    this.camera.quaternion.premultiply(this.quaternionY).multiply(this.quaternionX);
    this.emit('changeRotation', this.camera.rotation);
  }

  update() {
    this.isActive && this.onMove();
  }
}

现在来对以上代码进行一个简单的分析:

  1. 通过构造器传入相机和容器,因为我们需要操作这两个东西;
  2. 监听鼠标的按下/滑动/松开事件,来对视角切换进行操作;
  3. 当鼠标移动时,计算上一次到这一次的移动距离并保存;
  4. update方法渲染每一次的相机状态:
    1. 计算上一次和这次的滑动距离,以此作为切换的速度;
    2. 将计算出来的速度,作为旋转弧度对相机的四元数进行重新赋值;
    3. 通过premultiply方法来实现先进行Y轴旋转,再通过multiply进行X轴的旋转。

现在,再对该控件进行一个引用:

ts 复制代码
this.controls = new FirstPersonalControls(this.game.gameCamera.camera, this.game.gameRenderer.renderer.domElement);

update() {
  is.controls.update();
}

完成之后,我们再来看看效果,如下图所示:

标签实现

现在全景预览我们已经基本实现了,接下来就是给场景中添加一些标签来作交互。

标签的添加,方式也不是唯一的,这里通过Sprite配合CanvasTexture来实现:

  1. 首先简单的定义一个标签的信息文件:
ts 复制代码
export type TagInfo = {
  name: string;
  title: string;
  position: [number, number, number];
  target: string;
}
export const scene1Tags: TagInfo[] = [
  { name: 'out', title: '出去', position: [1, 0, -1], target: 'scene2' },
];

export const scene2Tags: TagInfo[] = [
  { name: 'road1', title: '大路', position: [1, -0.1, 0], target: 'scene3' },
  { name: 'road2', title: '小路', position: [-1, 0, -0.5], target: 'scene4' },
  { name: 'road3', title: '起始点', position: [0, 0, 1], target: 'scene1' },
];

export const scene3Tags: TagInfo[] = [
  { name: 'back', title: '去马路', position: [0, -0.1, 1], target: 'scene2' },
];

export const scene4Tags: TagInfo[] = [
  { name: 'back', title: '去马路', position: [0, 0, 1], target: 'scene2' },
];
  1. 定义一个标签Tag类,用来给场景添加标签:
ts 复制代码
import { EventEmitter } from 'events';
import { CanvasTexture, Mesh, Scene, Sprite, SpriteMaterial, Vector3 } from 'three';
import { Game } from '../index';
import { TagInfo } from './ts/tags';

export class Tag extends EventEmitter {
  game: Game;
  scene: Scene;
  sprite!: Sprite;
  constructor() {
    super();
    this.game = Game.getInstance();
    this.scene = this.game.gameScene.scene;
  }

  add(mesh: Mesh, tagInfo: TagInfo) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
    ctx.font = `16px 微软雅黑`;
    const textInfo = ctx.measureText(tagInfo.title);
    // 设置画布尺寸,确保宽度和高度足够容纳文字
    const width = textInfo.width;
    const height = textInfo.actualBoundingBoxAscent + textInfo.actualBoundingBoxDescent;
    canvas.width = width + 10;
    canvas.height = height + 10;
    ctx.beginPath();
    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.beginPath();
    ctx.font = `16px 微软雅黑`;
    ctx.fillStyle = '#fff';
    ctx.textBaseline = 'top';
    ctx.fillText(tagInfo.title, 5, 5);
    const texture = new CanvasTexture(canvas);
    const material = new SpriteMaterial({
      map: texture
    });
    this.sprite = new Sprite(material);
    this.sprite.scale.set(0.1, 0.08, 1);
    this.sprite.position.copy(new Vector3(...tagInfo.position));
    this.sprite.name = tagInfo.name;
    this.sprite.userData.target = tagInfo.target;
    mesh.add(this.sprite);
  }

  update() { }
}
  1. 修改之前初始化球体网格方法,如下:
ts 复制代码
  initSphere() {
    this.sphere = new SphereInstance();
    this.addScene('scene1', scene1Tags, 'acoustical_shell_1k');
    this.addScene('scene2', scene2Tags, 'rural_asphalt_road_1k');
    this.addScene('scene3', scene3Tags, 'kloofendal_43d_clear_1k');
    this.addScene('scene4', scene4Tags, 'resting_place_1k');
    this.scene2.position.set(2, 0, -4);
    this.scene3.position.set(4, 0, -8);
    this.scene4.position.set(-4, 0, -8);

    this.activeScene = this.scene1;
  }

  addScene(sceneName: SceneName, tags: TagInfo[], textureName: string) {
    const acoustical_shell_1k = this.game.resource.getHDR(textureName) as DataTexture;
    this[sceneName] = this.sphere.init(acoustical_shell_1k);
    tags.forEach(tagInfo => {
      this.tag.add(this[sceneName], tagInfo);
    });
  }

现在再进入页面,发现标签已经成功添加到球体网格中了,效果如下图所示:

场景切换

有了标签之后,我们就可以通过点击标签时进行场景的切换,设计思路如下:

  1. 通过Raycaster光线投射检测是否点击了标签;
  2. 通过标签对应的信息,把摄像机移动到下一个场景。

思路明确后,进行如下的代码编写:

ts 复制代码
  initEvent() {
    document.addEventListener('mousedown', evt => {
      const { offsetX, offsetY } = evt;
      this.mouse.x = offsetX / this.game.width * 2 - 1;
      this.mouse.y = -offsetY / this.game.height * 2 + 1;
      this.handleRaycaster();
    });
  }
  
  handleRaycaster() {
    const camera = this.game.gameCamera.camera;
    this.raycater.setFromCamera(this.mouse, camera);
    const tags = this.activeScene.children.filter(item => item.type === 'Sprite');
    const result = this.raycater.intersectObjects(tags);
    if (result.length) {
      const object = result[0].object as Sprite;
      const { x, y, z } = camera.position.clone().add(object.position);
      const radian = Math.atan2(
        camera.position.x - x,
        camera.position.z - z
      );
      const targetScene = this[object.userData.target as SceneName];
      camera.lookAt(targetScene.position);
      gsap.to(object.material, {
        opacity: 0, duration: 1, onComplete: () => {
          object.material.opacity = 1;
        }
      });
      gsap.to(camera.rotation, { y: radian, duration: 1 });
      gsap.to(camera.position, {
        x, y, z, duration: 1,
        onComplete: () => {
          const scene = targetScene;
          camera.position.copy(scene.position);
          this.activeScene = scene;
        }
      });
    }
  }

现在再来看看实现的效果,如下图所示:

结束

只是全景看图的预览功能实现还是比较简单的,只是其他的一些交互可能会麻烦一点,方案比较多,看各人喜欢怎么来实现。

对应资料

three.js官方网站
图片资源网站
代码仓库地址

相关推荐
点燃银河尽头的篝火(●'◡'●)14 分钟前
【BurpSuite】Cross-site scripting (XSS 学徒部分:1-9)
前端·web安全·网络安全·xss
Jiaberrr40 分钟前
手把手教你:微信小程序实现语音留言功能
前端·微信小程序·小程序·语音·录音
熊猫在哪40 分钟前
安装nuxt3
前端·nuxt.js
安冬的码畜日常1 小时前
【CSS in Depth 2 精译_036】5.6 Grid 网格布局中与对齐相关的属性 + 5.7本章小结
前端·css·css3·html5·网格布局·grid·css网格
先生沉默先2 小时前
Unity webgl跨域问题 unity使用nginx设置跨域 ,修改请求头
运维·nginx·webgl
啧不应该啊2 小时前
vue配置axios
前端·javascript·vue.js
__fuys__2 小时前
【HTML样式】加载动画专题 每周更新
前端·javascript·html
Want5952 小时前
HTML粉色烟花秀
前端·css·html
让开,我要吃人了3 小时前
HarmonyOS鸿蒙开发实战(5.0)自定义全局弹窗实践
前端·华为·移动开发·harmonyos·鸿蒙·鸿蒙系统·鸿蒙开发
yanlele3 小时前
前端面试第 66 期 - Vue 专题第二篇 - 2024.09.22 更新前端面试问题总结(20道题)
前端·javascript·面试