在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官方网站
图片资源网站
代码仓库地址

相关推荐
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅5 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊5 小时前
jwt介绍
前端
爱敲代码的小鱼5 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax