最近在学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.MeshBasicMaterial
或THREE.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开发全景看房功能
其实现原理是采用球体模拟全景。
由于上传文件大小有限制,只上传了客厅和厨房的场景效果。
实现源码:
<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";

// 厨房
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", // 点击要切换的全景图
},
],
},
];