效果:
threejs 3d Vr 看房
gitee 地址:
主要代码:
src/views/PanoramicView/index.vue
<script setup>
import {computed, onMounted, onUnmounted, reactive, ref} from 'vue'
import * as THREE from 'three'
// 引入轨道控制器扩展库OrbitControls.js
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {rooms} from './data.js';
import TWEEN from 'three/examples/jsm/libs/tween.module.js';
import Animations from "@/utils/animations.js";
let animationId
let tick;
const data = reactive({
renderer: null,
camera: null,
scene: null,
controls: null,
cameraZAxis: 2,
currentRoom: 'living-room',
});
// 获取交互点的信息
const interactivePoints = computed(() => {
const res = [];
rooms.forEach((room) => {
if (room.interactivePoints && room.interactivePoints.length > 0) {
room.interactivePoints.forEach((point) => {
point = {
room: room.key,
...point,
};
res.push(point);
});
}
});
return res;
});
const container = ref(null)
// 拿到页面的宽高
const width = window.innerWidth, height = window.innerHeight;
// 创建场景
let scene = new THREE.Scene();
// 将背景颜色设置为白色
scene.background = new THREE.Color("#000000");
data.scene = scene;
// 创建相机
let camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 1000);
// 设置相机位置
camera.position.z = data.cameraZAxis;
data.camera = camera;
// // 辅助线 AxesHelper
const axesHelper = new THREE.AxesHelper(500);
scene.add(axesHelper);
// 初始化渲染器
let renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
data.renderer = renderer;
// 设置相机控件轨道控制器OrbitControls
const controls = new OrbitControls(camera, renderer.domElement);
//阻尼 更真实
controls.enableDamping = true
// controls.enablePan = false;
// 缩放限制
controls.maxDistance = 12;
data.controls = controls;
// 监听浏览器窗口的大小变化 重新调整渲染器的大小
window.addEventListener('resize', onWindowResize);
// 更新渲染器大小以匹配新的浏览器窗口尺寸
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
const textLoader = new THREE.TextureLoader();
// 加载每个房间 (客厅、卧室、书房)
const createRoom = (name, position, map) => {
// 创建球状 为了做全景
const geometry = new THREE.SphereGeometry(16, 256, 256);
// z轴的-1 代表将外面的体图放在内侧
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;
}
const initRoom = () => {
// 创建网格对象
rooms.map((item) => {
return createRoom(item.key, item.position, item.map);
});
// 添加交互点
const raycaster = new THREE.Raycaster();
// 室内悬浮标记物
const _points = interactivePoints.value.map((item, index) => ({
...item,
element: document.querySelector(`.point-${index}`),
}));
// 动画
tick = () => {
if (renderer) {
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 * window.innerWidth * 0.5;
const translateY = -screenPosition.y * window.innerHeight * 0.5;
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`;
}
}
controls && controls.update();
TWEEN && TWEEN.update();
// 更新渲染器
renderer && renderer.render(scene, camera);
// 页面重绘时调用自身
window.requestAnimationFrame(tick);
};
tick();
}
// 点击交互点
const handleReactivePointClick = (point) => {
window.alert('你点击了' + point.value)
};
// 点击切换场景
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();
}
await sleep(1600);
data.currentRoom = room.key;
};
const sleep = (duration) => new Promise((resolve) => {
setTimeout(resolve, duration);
});
// 渲染页面
const render = () => {
if (!renderer) return
// 将场景(scene)和摄像机(camera 加入进来)
renderer.render(scene, camera)
// 渲染下一帧的时候会调用render函数
animationId = requestAnimationFrame(render)
controls.update()
}
const initLight = () => {
// 基本光源
const ambLight = new THREE.AmbientLight('#ffffff', 0.3)
/**
* 设置聚光灯相关的的属性
*/
const spotLight = new THREE.SpotLight(0xFFFFFF); // 聚光灯
spotLight.position.set(40, 200, 10);
spotLight.castShadow = true; // 只有该属性为true时,该点光源允许产生阴影,并且下列属性可用
scene.add(ambLight, spotLight); // 向场景中添加光源
}
onMounted(() => {
// 添加物体到场景
initRoom()
// 渲染
// render()
// 设置环境光
initLight()
// 将渲染加入到页面上
container.value.appendChild(renderer.domElement);
})
onUnmounted(() => {
window.removeEventListener('resize', onWindowResize)
// 销毁 Three.js 实例,清理内存
if (renderer) {
if (renderer.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
renderer.dispose();
renderer = null;
}
if (data.scene) {
data.scene.traverse((object) => {
if (object.geometry) object.geometry.dispose();
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach((material) => material.dispose());
} else {
object.material.dispose();
}
}
});
data.scene = null;
}
if (data.controls) {
data.controls = null
}
data.camera = null;
})
</script>
<template>
<div class="home">
<div ref="container"></div>
<div class="vr">
<span class="box">
<i class="icon"></i>
<b class="text">全景漫游</b>
</span>
</div>
<!-- 场景切换点 -->
<div class="switch">
<span class="button" v-for="(room, index) in rooms" :key="index" @click="handleSwitchButtonClick(room.key)"
v-show="room.key !== data.currentRoom">
<b class="text">{{ room.name }}</b>
<i class="icon"></i>
</span>
</div>
<!-- 交互点 -->
<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>
</div>
</template>
<style lang="css" scoped>
.home {
position: relative;
.vr {
position: fixed;
top: 0;
left: 0;
z-index: 11;
-webkit-animation: slideInLeft 1s .15s;
animation: slideInLeft 1s .15s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
.box {
background: rgba(0, 0, 0, .3);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: space-around;
overflow: hidden;
padding: 4px 20px;
border-radius: 0 0 16px 0;
border: 1px groove rgba(255, 255, 255, .3);
border-top: none;
border-left: none;
box-shadow: 0 1px 4px rgba(0, 0, 0, .1);
.icon {
display: inline-block;
height: 64px;
width: 64px;
background: url('@/assets/images/home/vr.png') no-repeat center;
background-size: contain;
margin-right: 12px;
}
.text {
font-size: 24px;
color: #ffffff;
display: inline-block;
font-weight: 500;
}
}
}
.switch {
position: fixed;
right: 24px;
top: 40%;
z-index: 11;
-webkit-animation: slideInRight 1s .3s;
animation: slideInRight 1s .3s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
.button {
display: block;
background: rgba(27, 25, 24, .5);
border-radius: 12px;
display: flex;
align-items: center;
padding: 12px 8px 12px 24px;
-webkit-backdrop-filter: blur(4px);
-moz-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
cursor: pointer;
transition: all .25s ease-in-out;
.text{
color: rgba(255, 255, 255, 1);
font-size: 24px;
font-weight: 600;
}
&:not(last-child) {
margin-bottom: 48px;
}
.icon {
display: inline-block;
height: 30px;
width: 30px;
background: url('@/assets/images/home/icon_arrow.png') no-repeat center;
background-size: 100% 100%;
transform: rotate(180deg);
margin-left: 8px;
}
&:hover {
background: rgba(27, 25, 24, .2);
box-shadow: 1px 1px 2px rgba(0, 0, 0, .2);
}
}
}
.point {
position: fixed;
top: 50%;
left: 50%;
z-index: 10;
.label {
position: absolute;
top: -16px;
left: -16px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(255, 255, 255, 1);
text-align: center;
line-height: 32px;
font-weight: 100;
font-size: 14px;
cursor: help;
transform: scale(0, 0);
transition: all 0.3s ease-in-out;
backdrop-filter: blur(4px);
&::before, &::after {
display: inline-block;
content: '';
background: rgba(255, 255, 255, 1);;
height: 100%;
width: 100%;
border-radius: 50%;
position: absolute;
left: 50%;
top: 50%;
margin-left: -10px;
margin-top: -10px;
}
&::before {
animation: bounce-wave 1.5s infinite;
}
&::after {
animation: bounce-wave 1.5s -0.4s infinite;
}
.label-tips {
height: 88px;
width: 200px;
overflow: hidden;
position: absolute;
top: -32px;
right: -220px;
font-size: 32px;
background: rgba(255, 255, 255, .6);
border: 1px groove rgba(255, 255, 255, .5);
-webkit-backdrop-filter: blur(4px);
-moz-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
border-radius: 16px;
display: flex;
justify-content: space-between;
align-content: center;
box-shadow: 1px 1px 2px rgba(0, 0, 0, .1);
.cover {
width: 80px;
height: 100%;
.icon {
display: inline-block;
height: 100%;
width: 100%;
filter: drop-shadow(1px 1px 4px rgba(0, 0, 0, .1));
}
}
.info {
width: calc(100% - 80px);
height: 100%;
overflow: hidden;
padding-left: 12px;
p {
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
text-shadow: 0 1px 1px rgba(0, 0, 0, .1);
&.p1 {
font-size: 24px;
color: #1D1F24;
font-weight: 800;
margin: 12px 0 2px;
}
&.p2 {
font-size: 18px;
color: #00aa47;
font-weight: 500;
}
}
}
}
&.label-sofa {
.label-tips {
left: -220px;
flex-direction: row-reverse;
.info {
padding: 0 12px 0 0;
p {
text-align: right
}
}
}
}
}
.text {
position: absolute;
top: 30px;
left: -120px;
width: 200px;
padding: 20px;
border-radius: 4px;
background: rgba(0, 0, 0, .6);
border: 1px solid #ffffff;
color: #ffffff;
line-height: 1.3em;
font-weight: 100;
font-size: 14px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
text-align: justify;
text-align-last: left;
}
&:hover .text {
opacity: 1;
}
&.visible .label {
transform: scale(1, 1)
}
}
}
.animate-point-wave::before {
content: '';
animation: bounce-wave 1.5s infinite;
}
.animate-point-wave::after {
content: '';
animation: bounce-wave 1.5s -0.4s infinite;
}
@keyframes bounce-wave {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(3.6);
opacity: 0;
}
}
</style>
src/views/PanoramicView/data.js
/* eslint-disable */
import { Vector3 } from 'three';
export const rooms = [
{
name: '客厅',
key: 'living-room',
map: new URL('@/assets/images/map/map_living_room.jpg', import.meta.url).href,
showSwitch: true,
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),
},
{
key: 'fridge',
value: '冰箱',
description: '豪华冰箱',
cover: new URL('@/assets/images/home/cover_living_room_fridge.png', import.meta.url).href,
position: new Vector3(-12, 4, 9),
},
{
key: 'sofa',
value: '沙发',
description: '舒适沙发',
cover: new URL('@/assets/images/home/cover_living_room_sofa.png', import.meta.url).href,
position: new Vector3(6, 0, -8),
},
],
},
{
name: '卧室',
key: 'bed-room',
map: new URL('@/assets/images/map/map_bed_room.jpg', import.meta.url).href,
showSwitch: true,
position: new Vector3(-32, 0, 0),
interactivePoints: [
{
key: 'bed',
value: '床',
description: '温暖的床',
cover: new URL('@/assets/images/home/cover_bed_room_bed.png', import.meta.url).href,
position: new Vector3(-38, 2, -14),
},
],
},
{
name: '书房',
key: 'study-room',
map: new URL('@/assets/images/map/map_study_room.jpg', import.meta.url).href,
showSwitch: true,
position: new Vector3(32, 0, 0),
interactivePoints: [
{
key: 'art',
value: '艺术品',
description: '绝版作品',
cover: new URL('@/assets/images/home/cover_study_room_art.png', import.meta.url).href,
position: new Vector3(42, 6, -8),
},
]
},
];
部分内容借鉴: