438 lines
10 KiB
Vue
438 lines
10 KiB
Vue
<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> |