VR全景看房

447 阅读7分钟

最近在学three.js 框架进行三维场景开发,研究了其官网提供的一些示例。都说学以致用,实践出真理。光是看别人写的项目,没有真正实践的话,成长总是有限的。所以最近我花了几天时间专门钻研了如何使用three.js 实现类似那样的VR功能。
接下来我会介绍下three.js 进行VR全景看房的原理,以及市面上一些好用的插件介绍。
注意: 文中用到了github一些开源项目的图片素材只用于学习所用。

一、VR全景实现原理

在three.js中实现全景图,主要有两种常见的思路:一种是使用球体(Sphere)来模拟全景,另一种是使用立方体(Cube)纹理(也称为天空盒或环境映射)来创建全景效果。以下是这两种方法的详细思路:

1. 使用球体(Sphere)模拟全景

1.1 思路

  • 创建球体几何体:使用THREE.SphereGeometry(或更高效的THREE.SphereBufferGeometry)来创建一个球体几何体。这个球体将作为全景图的载体。
  • 准备全景图:确保你有一张完整的360度全景图像。这张图像应该能够无缝地环绕在球体上。
  • 设置材质:创建一个材质(如THREE.MeshBasicMaterial),并将全景图设置为材质的纹理。确保纹理的映射方式(UV映射)能够正确地将全景图包裹在球体上。
  • 创建网格并添加到场景:将球体几何体和材质结合,创建一个网格(THREE.Mesh),然后将这个网格添加到three.js的场景中。
  • 设置相机:使用THREE.PerspectiveCamera,并将其放置在球体的中心或稍微偏移的位置,以便能够观察到整个球体。
  • 添加交互:为了使用户能够查看全景图的不同部分,你可以添加鼠标或触摸事件监听器来控制相机的旋转。了使用户能够查看全景图的不同部分,你可以添加鼠标或触摸事件监听器来控制相机的旋转。

1.2 优点

  • 实现简单直观
  • 适用于大多数全景图像

1.3 缺点

  • 在球体顶部和底部可能会有轻微的失真(取决于全景图的制作和纹理映射方式)

2.使用立方体纹理(Cube Texture)

2.1 思路

  • 准备立方体纹理:立方体纹理由六张图像组成,分别代表立方体的六个面(前、后、左、右、上、下)。这些图像应该构成一个完整的360度全景环境。
  • 创建材质:使用THREE.MeshBasicMaterialTHREE.MeshStandardMaterial,并将立方体纹理设置为材质的envMap属性。
  • 创建网格:虽然立方体纹理通常用于环境映射,但你也可以将其应用于任何网格上,包括一个简单的平面或球体。然而,在全景视图中,更常见的是将其应用于一个假想的立方体内部(通过一些技巧来模拟),或者直接将其用于特定的VR渲染器。
  • 设置相机:对于立方体纹理,相机的设置可能会根据你的应用场景有所不同。如果你正在为VR创建内容,那么相机设置将涉及VR特定的库和设置。
  • VR集成:一般借助库实现。

2.2 优点

  • 适用于需要高度真实环境映射的场景。
  • 在VR应用中非常有用。

2.3 缺点

  • 需要准备六张图像来创建立方体纹理。
  • 实现可能相对复杂,特别是如果你不熟悉VR开发或three.js的高级功能。

二、一些好用插件介绍

如果不借助插件,直接使用three进行全景功能开发,复杂度还是有点高。下面我将会介绍三个我搜罗到的好用插件:

1.1 marzipano

基于 three, 文档简单,官网提供了一个工具通过可视化形式生成全景图,并支持下载代码。
官方文档是英文的,如果亲们想阅读中文文档,可以查看我前面发表的翻译博客。 juejin.cn/post/738136…

1.2 photo-sphere-viewer

基础three.js ,文档简单,提供了一些plugin插件帮助我们快速开发。

1.3 pannellum

它是基于WebGL实现的,所以如果使用该库来实现,需要掌握一定的 WebGL 相关理论。

三、使用photo-sphere-viewer开发全景看房功能

其实现原理是采用球体模拟全景。
由于上传文件大小有限制,只上传了客厅和厨房的场景效果。

全景看房.gif

实现源码:

<template>
  <div id="viewer" style="width: 100vw; height: 100vh"></div>
  <tinyMap
    class="tiny"
    :rotate="tiny.rotate"
    :position="tiny.position"
  ></tinyMap>
</template>

<script>
import tinyMap from "./components/tinyMap.vue";
import { Viewer } from "@photo-sphere-viewer/core";
import { MarkersPlugin } from "@photo-sphere-viewer/markers-plugin";
// import { MapPlugin } from "@photo-sphere-viewer/map-plugin";
import "@photo-sphere-viewer/core/index.css";
import "@photo-sphere-viewer/markers-plugin/index.css";
// import "@photo-sphere-viewer/map-plugin/index.css";
import data from "./data.js";
// import "photo-sphere-viewer/dist/plugins/markers.css";
import interactive from "./assets/panorama/interactive.png";
import iconArrow from "./assets/panorama/icon_arrow.png";
import tinyMapHome from "./assets/panorama/tiny_map_home.png";

import coverLivingRoomArt from "./assets/panorama/cover_living_room_art.png";

export default {
  components: {
    tinyMap,
  },
  data() {
    return {
      viewer: "",
      config: data[0],
      tiny: {
        rotate: 0,
        position: {
          left: 55,
          top: 80,
        },
      },
    };
  },
  methods: {
    // 生成家具热点
    renderFurnitureMarkers({ img, title, subTitle, className = "" }) {
      return `
        <div class="marker ${className}">
          <img src="${img}" class="marker-left" />
          <div class="marker-right">
            <p class="marker-title">${title}</p>
            <p class="marker-sub-title">${subTitle}</p>
          </div>
        </div>
      `;
    },
    // 生成空间热点
    renderSpaceMarkers({ title, className = "" }) {
      return `
        <div class="marker-space ${className}">
          <img src="${iconArrow}" class="marker-space-left" />
          <span class="marker-space-right">${title}</span>
        </div>
      `;
    },

    // 处理热点数据
    formatMarkersData(data) {
      return data.map((item) => {
        let tooltip = {};
        if (item.markerCon) {
          item.html = this.renderFurnitureMarkers(item.markerCon);
        }
        if (item.switchConfig) {
          item.html = this.renderSpaceMarkers(item.switchConfig);
        }
        return item;
      });
    },
  },
  mounted() {
    // const markersPlugin = this.viewer.getPlugin(MarkersPlugin);
    this.viewer = new Viewer({
      container: document.querySelector("#viewer"),
      panorama: this.config.panoramaImg,
      size: {
        width: "100vw",
        height: "100vh",
      },
      plugins: [
        [
          MarkersPlugin,
          {
            markers: this.formatMarkersData(this.config.markers),
          },
        ],
        // [
        //   MapPlugin, {
        //     imageUrl: tinyMapHome,
        //     center: { x: 90, y: 90 },
        //     // rotation: '-12deg',
        //     size: '180px'
        //   }
        // ]
      ],
    });

    // 监听位置变化, 户型图联动的模拟出来了!!
    // 如何监听旋转角度!!
    this.viewer.addEventListener("position-updated", ({ position }) => {
      // 近似的设置位置
      switch (this.config.id) {
        // 客厅
        case "mapLivingRoom":
          // console.log(`yaw: ${position.yaw}, pitch: ${position.pitch}`);
          if (position.yaw >= 5.1 && position.yaw <= 5.915632262707879) {
            this.tiny.position = {
              left: 40,
              top: 90,
            };
          } else if (
            position.yaw >= 4.554575154032087 &&
            position.yaw <= 4.922634151884458
          ) {
            this.tiny.position = {
              left: 40,
              top: 113,
            };
          } else if (
            position.yaw >= 1.669694343365568 &&
            position.yaw <= 1.7780960428318062
          ) {
            this.tiny.position = {
              left: 59,
              top: 113,
            };
          }
          break;
      }
    });

    // 鼠标点击全景图,显示对应的 x y 轴坐标
    this.viewer.addEventListener("click", ({ data }) => {
      console.log(`yaw: ${data.yaw} , pitch: ${data.pitch}`);
    });

    const markersPlugin = this.viewer.getPlugin(MarkersPlugin);
    markersPlugin.addEventListener("select-marker", ({ marker }) => {
      const targetPanorama = marker.config.targetPanorama;
      if (targetPanorama) {
        // 查找该场景的所有配置信息
        const curCon = data.filter((item) => item.id === targetPanorama)[0];
        // 如果是场景切换,就先清除上一个场景的热点,增加当前场景的热点!!
        const ids = this.config.markers.map((item) => item.id);
        this.config = curCon;
        markersPlugin.removeMarkers(ids);
        this.viewer.setPanorama(curCon.panoramaImg).then(() => {
          this.viewer
            .animate({
              zoom: 50,
              speed: 500,
            })
            .then(() => {
              this.formatMarkersData(curCon.markers).forEach((item) => {
                markersPlugin.addMarker(item);
              });
            });
        });
      }
    });
  },
};
</script>

数据定义(data.js):

// 客厅
import mapLivingRoom from "./assets/panorama/map_living_room.jpg";
import coverLivingRoomPlant from "./assets/panorama/cover_living_room_plant.png";
import coverLivingRoomTv from "./assets/panorama/cover_living_room_tv.png";
import coverLivingRoomArt from "./assets/panorama/cover_living_room_art.png";
import coverLivingRoomSofa from "./assets/panorama/cover_living_room_sofa.png";

![VR看房.gif](图片体积不能大于20MB)
// 厨房
import mapKitchen from "./assets/panorama/map_kitchen.jpg";
import coverKitchenFruit from "./assets/panorama/cover_kitchen_fruit.png";
import coverKitchenFridge from "./assets/panorama/cover_kitchen_fridge.png";

// 卧室
import mapDedRoom from "./assets/panorama/map_bed_room.jpg";
import coverDedRoomDed from "./assets/panorama/cover_bed_room_bed.png";

// 洗漱间
import mapHall from "./assets/panorama/map_hall.jpg";

// 卫生间
import mapBathRoom from "./assets/panorama/map_bath_room.jpg";

/***
 * 存在问题:
 * 1. 场景热点,位置是不是应该是固定写死的? 毕竟如果小层级,他们会重叠在一起不好看
 * 2. 热点怎么做到旋转效果? 抑或是不需要做旋转呢
 */

export default [
  // 客厅
  {
    id: "mapLivingRoom",
    panoramaImg: mapLivingRoom,
    markers: [
      {
        id: "plant",
        size: { width: 30, height: 30 },
        position: { yaw: -0.73, pitch: 0.19 },
        markerCon: {
          img: coverLivingRoomPlant,
          title: "绿植",
          subTitle: "自由呼吸",
        },
      },
      {
        id: "tv",
        size: { width: 30, height: 30 },
        position: { yaw: 5.92564519821881, pitch: -0.02622642155298327 },
        markerCon: {
          img: coverLivingRoomTv,
          title: "电视",
          subTitle: "智能电视",
          className: "reverse",
        },
      },
      {
        id: "art",
        size: { width: 30, height: 30 },
        position: {
          yaw: 0.6719157949042762, pitch: -0.05924986997827686
        },
        markerCon: {
          img: coverLivingRoomArt,
          title: "艺术品",
          subTitle: "小人",
        },
      },
      {
        id: "sofa",
        size: { width: 30, height: 30 },
        position: {
          yaw: 3.1453675358449122,
          pitch: -0.3996687194380282,
        },
        markerCon: {
          img: coverLivingRoomSofa,
          title: "沙发",
          subTitle: "躺沙发",
        },
      },
      {
        id: "kitchen",
        size: { width: 30, height: 30 },
        position: {
          yaw: 1.4287714114447958 , pitch: -0.048262522802073216
        },
        switchConfig: {
          title: "厨房",
        },
        targetPanorama: "mapKitchen", // 点击要切换的全景图
      },
      {
        id: "hall",
        size: { width: 30, height: 30 },
        position: {
          yaw: 0.9743284916311524,
          pitch: 0.01653481526345102,
        },
        // 无法旋转,注意处理下
        // rotation: {pitch: Math.PI / 2},
        switchConfig: {
          title: "走廊",
        },
        targetPanorama: "mapHall", // 点击要切换的全景图
      },
      {
        id: "bathRoom",
        size: { width: 30, height: 30 },
        position: {
          yaw: 0.9743284916311524,
          pitch: 0.10737032759670773,
        },
        // 无法旋转,注意处理下
        // rotation: {pitch: Math.PI / 2},
        switchConfig: {
          title: "卫生间",
        },
        targetPanorama: "mapBathRoom", // 点击要切换的全景图
      },
      {
        id: "dedRoom",
        size: { width: 30, height: 30 },
        position: {
          yaw: 0.9743284916311524,
          pitch: -0.08037032759670773,
        },
        // 无法旋转,注意处理下
        // rotation: {pitch: Math.PI / 2},
        switchConfig: {
          title: "卧室",
        },
        targetPanorama: "mapDedRoom", // 点击要切换的全景图
      },
    ],
  },
  //   厨房
  {
    id: "mapKitchen",
    panoramaImg: mapKitchen,
    // yaw: 0.3030738499051215  pitch: -0.25449967760517733
    markers: [
      {
        id: "fruits",
        size: { width: 30, height: 30 },
        position: {
          yaw: 0.4382064470569331,
          pitch: -0.1537935582603472,
        },
        markerCon: {
          img: coverKitchenFruit,
          title: "水果",
          subTitle: "美味食物",
        },
      },
      {
        id: "fridge",
        size: { width: 30, height: 30 },
        position: {
          yaw: 1.7759920671506613,
          pitch: 0.07160733021681898,
        },
        markerCon: {
          img: coverKitchenFridge,
          title: "冰箱",
          subTitle: "智能家电",
        },
      },
      {
        id: "livingRoom",
        size: { width: 30, height: 30 },
        position: {
          yaw: 5.624489485156981,
          pitch: -0.1537935582603472,
        },
        switchConfig: {
          title: "客厅",
        },
        targetPanorama: "mapLivingRoom", // 点击要切换的全景图
      },
    ],
  },

  //   走廊
  {
    id: "mapHall",
    panoramaImg: mapHall,
    markers: [
      {
        id: "hallBath",
        size: { width: 30, height: 30 },
        position: {
          yaw: 5.555239247401611,
          pitch: -0.1459145625659326,
        },
        switchConfig: {
          title: "卫生间",
        },
        targetPanorama: "mapBathRoom", // 点击要切换的全景图
      },
      {
        id: "hallLiving",
        size: { width: 30, height: 30 },
        position: {
          yaw: 0.7251676257424398,
          pitch: 0.14606712642800536,
        },
        switchConfig: {
          title: "客厅",
          className: "reverse",
        },
        targetPanorama: "mapLivingRoom", // 点击要切换的全景图
      },
      {
        id: "hallDed",
        size: { width: 30, height: 30 },
        position: {
          yaw: 0.7251676257424398,
          pitch: -0.012649194979715617,
        },
        switchConfig: {
          title: "卧室",
          className: "reverse",
        },
        targetPanorama: "mapDedRoom", // 点击要切换的全景图
      },
      {
        id: "hallKitchen",
        size: { width: 30, height: 30 },
        position: {
          yaw: 0.7251676257424398,
          pitch: -0.17585033521852655,
        },
        switchConfig: {
          title: "厨房",
          className: "reverse",
        },
        targetPanorama: "mapKitchen", // 点击要切换的全景图
      },
    ],
  },

  //   卫生间
  {
    id: "mapBathRoom",
    panoramaImg: mapBathRoom,
    markers: [
      {
        id: "bathHall",
        size: { width: 30, height: 30 },
        position: {
          yaw: 1.2502341134171053,
          pitch: -0.09727504985190416,
        },
        switchConfig: {
          title: "走廊",
        },
        targetPanorama: "mapHall", // 点击要切换的全景图
      },
      {
        id: "bathLiving",
        size: { width: 30, height: 30 },
        position: {
          yaw: 1.431603311880381,
          pitch: -0.30817215393957453,
        },
        switchConfig: {
          title: "客厅",
          className: "reverse",
        },
        targetPanorama: "mapLivingRoom", // 点击要切换的全景图
      },
      {
        id: "bathDed",
        size: { width: 30, height: 30 },
        position: {
          yaw: 1.431603311880381,
          pitch: -0.45697248764720964,
        },
        switchConfig: {
          title: "卧室",
          className: "reverse",
        },
        targetPanorama: "mapDedRoom", // 点击要切换的全景图
      },
      {
        id: "bathKitchen",
        size: { width: 30, height: 30 },
        position: {
          yaw: 1.431603311880381,
          pitch: -0.5780254336539343,
        },
        switchConfig: {
          title: "厨房",
          className: "reverse",
        },
        targetPanorama: "mapKitchen", // 点击要切换的全景图
      },
    ],
  },
  //   卧室
  {
    id: "mapDedRoom",
    panoramaImg: mapDedRoom,
    markers: [
      {
        id: "bed",
        size: { width: 30, height: 30 },
        position: {
          yaw: 0.01804673461447182,
          pitch: -0.09339564949725054,
        },
        markerCon: {
          img: coverDedRoomDed,
          title: "床",
          subTitle: "肥宅的窝",
        },
      },
      {
        id: "bedRoomHall",
        size: { width: 30, height: 30 },
        position: {
          yaw: 4.530132700341807,
          pitch: -0.053888497372133415,
        },
        switchConfig: {
          title: "走廊",
        },
        targetPanorama: "mapHall", // 点击要切换的全景图
      },
    ],
  },
];