Files
RentWeAppFront/pages-biz/vr/vr.vue
2026-01-15 17:18:24 +08:00

438 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="vr-page">
<!-- 返回按钮 -->
<view class="back-btn" @click="back"> 返回</view>
<!-- Canvas -->
<canvas canvas-id="vrCanvas" class="vr-canvas" @touchstart="onTouchStart" @touchmove="onTouchMove"
@touchend="onTouchEnd"></canvas>
<!-- 左上角 3D mini preview -->
<canvas v-if="currentSpace.type==='model'" canvas-id="miniCanvas" class="mini-canvas"></canvas>
<!-- Hotspot 点击由 Three.js raycaster 处理 -->
<!-- 底栏 -->
<view class="bottom-bar">
<!-- 左侧空间切换 -->
<view class="left">
<button @click="showSpaceList = !showSpaceList">
<image src="/static/icons/space.png" />
</button>
</view>
<!-- 右侧工具 -->
<view class="right">
<button @click="showTools = !showTools">
<image src="/static/icons/tools.png" />
</button>
</view>
</view>
<!-- 空间缩略图弹窗 -->
<view v-if="showSpaceList" class="space-list">
<view v-for="space in spaces" :key="space.id" class="space-item" @click="switchSpace(space.id)">
<image :src="space.thumbnail" />
<text>{{ space.name }}</text>
</view>
</view>
<!-- 工具栏弹窗 -->
<view v-if="showTools" class="tools-panel">
<button @click="resetView">重置视角</button>
<button @click="toggleFullScreen">全屏</button>
<button @click="takePhoto">拍照</button>
</view>
<!-- 上一/下一空间按钮 -->
<view class="switch-btn left" @click="prevSpace"></view>
<view class="switch-btn right" @click="nextSpace"></view>
</view>
</template>
<script>
import * as THREE from 'three'
import {
GLTFLoader
} from 'three/examples/jsm/loaders/GLTFLoader.js'
export default {
name: 'VRView',
data() {
return {
spaces: [], // VR 空间数组
currentIndex: 0,
currentSpace: null,
// Three.js 主场景
renderer: null,
scene: null,
camera: null,
sphere: null,
videoTexture: null,
// Mini 3D模型
miniRenderer: null,
miniScene: null,
miniCamera: null,
miniModel: null,
// Hotspot
raycaster: null,
mouse: new THREE.Vector2(),
// 触控
isDragging: false,
lastX: 0,
lastY: 0,
lon: 0,
lat: 0,
// UI
showSpaceList: false,
showTools: false
}
},
onLoad(options) {
if (options.vrList) {
try {
// JSON.parse(decodeURIComponent(options.spaces))
this.spaces = options.vrList
} catch (e) {
console.error('解析 VR 空间失败', e)
}
}
this.currentSpace = this.spaces[this.currentIndex]
this.initThree()
},
methods: {
back() {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
} else {
uni.switchTab({
url:'/pages/index/index'
})
}
},
// ==================== Three.js 主场景 ====================
initThree() {
const canvasQuery = uni.createSelectorQuery()
canvasQuery.select('#vrCanvas').node().exec(res => {
const canvasNode = res[0].node
const {
windowWidth: width,
windowHeight: height
} = uni.getSystemInfoSync()
// 主 renderer
this.renderer = new THREE.WebGLRenderer({
canvas: canvasNode,
antialias: true
})
this.renderer.setSize(width, height)
// scene & camera
this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 2000)
this.camera.position.set(0, 0, 0.01)
// raycaster
this.raycaster = new THREE.Raycaster()
this.loadSpace(this.currentSpace)
this.animate()
})
},
// 加载空间
loadSpace(space) {
// 清空场景
while (this.scene.children.length) this.scene.remove(this.scene.children[0])
if (this.videoTexture) {
this.videoTexture = null
}
// 图片
if (space.type === 'image') {
const geometry = new THREE.SphereGeometry(500, 60, 40)
geometry.scale(-1, 1, 1)
const texture = new THREE.TextureLoader().load(space.src)
const material = new THREE.MeshBasicMaterial({
map: texture
})
this.sphere = new THREE.Mesh(geometry, material)
this.scene.add(this.sphere)
}
// CubeMap
else if (space.type === 'cubemap') {
const urls = [space.cubemap.px, space.cubemap.nx, space.cubemap.py, space.cubemap.ny, space.cubemap.pz,
space.cubemap.nz
]
const cubeTexture = new THREE.CubeTextureLoader().load(urls)
this.scene.background = cubeTexture
}
// 视频
else if (space.type === 'video') {
const video = document.createElement('video')
video.src = space.src
video.crossOrigin = 'anonymous'
video.loop = true
video.muted = false
video.autoplay = true
video.play()
this.videoTexture = new THREE.VideoTexture(video)
const geometry = new THREE.SphereGeometry(500, 60, 40)
geometry.scale(-1, 1, 1)
const material = new THREE.MeshBasicMaterial({
map: this.videoTexture
})
this.sphere = new THREE.Mesh(geometry, material)
this.scene.add(this.sphere)
}
// 模型
else if (space.type === 'model') {
const loader = new GLTFLoader()
loader.load(space.src, gltf => {
this.sphere = gltf.scene
this.sphere.scale.set(space.modelScale, space.modelScale, space.modelScale)
this.scene.add(this.sphere)
this.initMiniModel(gltf.scene.clone(), space.modelScale)
})
}
// Hotspots
if (space.hotspots && space.hotspots.length) {
space.hotspots.forEach(h => {
const spriteMap = new THREE.TextureLoader().load('/static/icons/hotspot.png')
const spriteMaterial = new THREE.SpriteMaterial({
map: spriteMap
})
const sprite = new THREE.Sprite(spriteMaterial)
const phi = THREE.MathUtils.degToRad(90 - h.position.lat)
const theta = THREE.MathUtils.degToRad(h.position.lon)
const radius = 500
sprite.position.set(
radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta)
)
sprite.userData.targetId = h.targetId
this.scene.add(sprite)
})
}
},
animate() {
requestAnimationFrame(this.animate)
// 相机旋转
const phi = THREE.MathUtils.degToRad(90 - this.lat)
const theta = THREE.MathUtils.degToRad(this.lon)
this.camera.target = new THREE.Vector3(
500 * Math.sin(phi) * Math.cos(theta),
500 * Math.cos(phi),
500 * Math.sin(phi) * Math.sin(theta)
)
this.camera.lookAt(this.camera.target)
this.renderer.render(this.scene, this.camera)
// mini 模型同步旋转
if (this.currentSpace.type === 'model' && this.miniModel) {
this.miniModel.rotation.y = this.camera.rotation.y
this.miniRenderer.render(this.miniScene, this.miniCamera)
}
},
// ==================== Mini 3D 模型 ====================
initMiniModel(model, scale) {
const canvasQuery = uni.createSelectorQuery()
canvasQuery.select('#miniCanvas').node().exec(res => {
const canvasNode = res[0].node
this.miniRenderer = new THREE.WebGLRenderer({
canvas: canvasNode,
antialias: true,
alpha: true
})
this.miniRenderer.setSize(150, 150)
this.miniScene = new THREE.Scene()
this.miniCamera = new THREE.PerspectiveCamera(75, 1, 0.1, 2000)
this.miniCamera.position.set(0, 50, 100)
this.miniModel = model
this.miniScene.add(this.miniModel)
})
},
// ==================== 空间切换 ====================
switchSpace(id) {
const index = this.spaces.findIndex(s => s.id === id)
if (index >= 0) {
this.currentIndex = index
this.currentSpace = this.spaces[index]
this.loadSpace(this.currentSpace)
this.showSpaceList = false
}
},
prevSpace() {
this.currentIndex = (this.currentIndex - 1 + this.spaces.length) % this.spaces.length;
this.switchSpace(this.spaces[this.currentIndex].id)
},
nextSpace() {
this.currentIndex = (this.currentIndex + 1) % this.spaces.length;
this.switchSpace(this.spaces[this.currentIndex].id)
},
// ==================== 手势 ====================
onTouchStart(e) {
this.isDragging = true;
const t = e.touches[0];
this.lastX = t.clientX;
this.lastY = t.clientY
},
onTouchMove(e) {
if (!this.isDragging) return;
const t = e.touches[0];
const dx = t.clientX - this.lastX;
const dy = t.clientY - this.lastY;
this.lastX = t.clientX;
this.lastY = t.clientY;
this.lon -= dx * 0.1;
this.lat += dy * 0.1;
this.lat = Math.max(-85, Math.min(85, this.lat))
},
onTouchEnd() {
this.isDragging = false
},
// ==================== 工具栏功能示例 ====================
resetView() {
this.lon = 0;
this.lat = 0
},
toggleFullScreen() {
uni.setScreenBrightness({
value: 1
})
}, // 示例
takePhoto() {
console.log('拍照功能可扩展')
}
}
}
</script>
<style scoped>
.vr-page {
width: 100%;
height: 100%;
background: #000;
position: relative
}
.vr-canvas {
width: 100%;
height: 100%;
display: block
}
.mini-canvas {
position: absolute;
top: 20rpx;
left: 20rpx;
width: 150rpx;
height: 150rpx;
z-index: 10;
border: 1px solid #ccc;
border-radius: 6rpx
}
.back-btn {
position: absolute;
top: 40rpx;
left: 20rpx;
color: #fff;
font-size: 28rpx;
z-index: 10
}
.bottom-bar {
position: absolute;
bottom: 0;
width: 100%;
height: 80rpx;
display: flex;
justify-content: space-between;
padding: 0 20rpx;
z-index: 10
}
.bottom-bar .left,
.bottom-bar .right {
display: flex;
align-items: center
}
.space-list {
position: absolute;
bottom: 80rpx;
left: 0;
width: 100%;
height: 150rpx;
background: rgba(0, 0, 0, 0.7);
display: flex;
overflow-x: scroll;
padding: 10rpx
}
.space-list .space-item {
margin-right: 10rpx;
display: flex;
flex-direction: column;
align-items: center
}
.space-list image {
width: 120rpx;
height: 80rpx;
border-radius: 6rpx
}
.tools-panel {
position: absolute;
bottom: 80rpx;
right: 0;
width: 200rpx;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
padding: 10rpx
}
.tools-panel button {
margin-bottom: 10rpx;
color: #fff
}
.switch-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
color: #fff;
font-size: 60rpx;
background: rgba(0, 0, 0, 0.3);
width: 80rpx;
height: 80rpx;
line-height: 80rpx;
text-align: center;
border-radius: 50%
}
.switch-btn.left {
left: 20rpx
}
.switch-btn.right {
right: 20rpx
}
</style>