简介:一份基于 Three.js 的 VR 全景跳转项目源码及说明文档,参考贝壳找房全景看房的交互方式,适合计算机、数学、电子信息等专业学生作为课程设计、期末大作业或毕业设计参考资料。项目包含全景场景切换的核心逻辑、可交互操作界面、配套项目说明,能够帮助读者理解三维全景展示与场景跳转的实现思路,并快速上手 React 与三维渲染结合的前端开发。压缩包共 51 个文件,大小 12.61MB,其中 18 个 jpg 与 14 个 png 为主要 VR 全景图、场景素材与界面贴图,js、css、html 构建页面与三维场景主体,tsx、json、md 等文件补充组件、依赖配置与使用说明。工程目录包含 src 源码与 public 静态资源,结构清晰易懂,配有 README,便于按模块阅读、调试和二次开发。目前已有 433 人学习浏览,对需要快速实现 VR 全景跳转功能、完成课程作业或扩展毕业设计场景的读者来说,是一份可直接运行借鉴的实用源码。
1. 全景跳转其实是一个状态机问题
看到“three VR 全景跳转”这个标题,很容易让人以为难点在 Three.js 的渲染上,但实际上把球体贴上全景图这件事并不难,真正的核心是“跳转”。在贝壳找房这类产品里,用户站在一个房间内,点击门洞或箭头进入另一个房间,视角连续切换,背后是多个全景球体之间的状态机迁移。因此需要解决的是一套完整的数据结构:每个场景有哪些热点、热点指向哪个目标场景、切换时旧纹理何时销毁、新纹理何时加载、VR 模式下控制器如何与场景交互。这个 zip 把源码和项目说明打包在一起,本质上是在给一个 3D 场景编辑器式的工程做骨架。适合前端工程师、WebXR 学习者和房产信息化的运营开发同学参照。下面从最小渲染工程开始拆解,再落到跳转逻辑和参数调优。
2. 用 Three.js 搭出 VR 全景球体的最小工程
2.1 为什么选 Three.js 而不是全景播放器
市面上很多 VR 看房方案直接套用 photo sphere viewer 等插件,优点是上手快,但一旦需要自定义热点、楼层切换和带动画的过渡,就会被插件 API 卡住。Three.js 直接把全景球体建模为一个 Mesh,纹理、材质、光线和交互都是显式控制。这意味着热点跳转不再是对某个库的 hack,而是对 Mesh 和 Camera 的正常操作。
Three.js 对 WebXR 的支持也比较完整,VRButton 可以一键进入 VR 模式,点击事件在 VR 控制器上可以通过selectstart事件统一处理。选择 Three.js 相当于把方案建立在通用的 3D 引擎之上,后续加模型、加漫游路径都不会推翻重来。
2.2 场景、相机、渲染器的最小代码
工程入口通常是src/main.js。全景看房需要的相机不是透视视角,而是位于球体中心点,这样纹理才能形成环绕效果。最小代码如下:
import * as THREE from 'three'; import { Scene, PerspectiveCamera, WebGLRenderer } from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { VRButton } from 'three/addons/webxr/VRButton.js'; const scene = new Scene(); scene.background = new THREE.Color(0x202020); const camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 0, 0); const renderer = new WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); renderer.xr.enabled = true; document.body.appendChild(renderer.domElement); document.body.appendChild(VRButton.createButton(renderer)); const controls = new OrbitControls(camera, renderer.domElement); controls.enableZoom = false; controls.rotateSpeed = 0.5; controls.target.set(0, 0, 0); scene.add(camera); function animate() { renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); }); } animate();这段代码里最关键的是camera.position.set(0, 0, 0)和controls.target都设置在原点。全景球的中心固定在原点,相机固定在球心,通过旋转相机来改变视角,不移动位置。OrbitControls的enableZoom必须关闭,否则用户会平移出球体内部。renderer.setAnimationLoop是 Three.js 在 WebXR 模式下的推荐循环方式,它会在 VR 会话期间自动调整帧率。
2.3 全景球体:纹理贴图的正确姿态
全景图一般是等距柱状投影(equirectangular),宽高比 2:1,代表 360 度水平视角和 180 度垂直视角。将纹理贴到球体内部,需要使用 SphereGeometry,并调整材质参数让纹理作用于球体内侧。
const textureLoader = new THREE.TextureLoader(); const panoramaTexture = textureLoader.load('/textures/room_1.jpg'); const geometry = new THREE.SphereGeometry(500, 60, 40); const material = new THREE.MeshBasicMaterial({ map: panoramaTexture, side: THREE.BackSide }); const sphere = new THREE.Mesh(geometry, material); scene.add(sphere);球体半径 500 是为了让相机的近裁面与远裁面之间留出足够空间,同时避免出现穿模。THREE.BackSide使球体只渲染内部表面,这是全景显示的关键。如果使用MeshStandardMaterial并加入灯光,反而会造成全景图亮度不一致,因此这里使用MeshBasicMaterial不受光照影响,纹理颜色更接近原图。
加载纹理时需要设置纹理颜色空间,否则在较新的 Three.js 版本中会偏灰:
texture.colorSpace = THREE.SRGBColorSpace; texture.mapping = THREE.EquirectangularReflectionMapping; texture.mapping = THREE.EquirectangularReflectionMapping; // 用于环境映射但注意一个细节:如果直接把纹理赋给 MeshBasicMaterial 的map属性,equirectangularMapping并不需要。 这个映射常量通常用于scene.environment或scene.background。 最可靠的方式是让纹理保持默认 UV 映射,与 SphereGeometry 的 UV 展开一致即可。
2.4 VR 模式:控制器进入后如何查看
VRButton 接入了 WebXR,但全景场景中用户不需要移动位置,所以控制器的主要用途是点击热点。后续章节会在射线拾取部分加入对 VR 手柄selectstart事件的监听。在本地开发时,用 Chrome 的 WebXR 模拟器插件即可调试,不必每次戴头显。代码层面需要在renderer.xr.enabled = true之后,通过renderer.setAnimationLoop来驱动画面,而不是使用requestAnimationFrame,否则 VR 设备的原生帧率无法正确被渲染器捕捉。
3. 热点跳转的核心逻辑:射线拾取与场景切换协议
3.1 先设计热点数据结构
跳转功能不能把目标场景 ID 硬编码在事件回调里,否则新增一个楼盘就要改一次代码。更好的做法是把场景和热点抽成数据驱动,用 JSON 描述每个节点。参考贝壳看房的组织方式,每个全景图文件对应一个场景节点,场景之间通过热点的target字段形成关系图。
{ "scenes": [ { "id": "room_1", "panorama": "textures/room_1.jpg", "hotspots": [ { "id": "hs_1", "name": "客厅", "direction": [0, 0, -1], "target": "room_2" } ] } ] }direction是从球心指向热点的方向向量。这个向量的含义代表用户看到空间中某个固定位置。热点本身并不是一个平面按钮,而是一个 3D 空间中的小几何体,例如一个圆环或圆点。它被放置在球体内表面附近,用户旋转视角时,热点像是在墙面上固定不动。
3.2 射线拾取:鼠标与手柄共用一套逻辑
鼠标点击时,必须把屏幕坐标转换成三维射线,判断是否与热点模型相交。VR 控制器手柄则直接使用控制器自身的矩阵发出射线。两种输入可以抽象成同一个函数。
function registerRaycastSource(raycaster, srcObject, action) { srcObject.addEventListener('selectstart', (event) => { const controller = event.target; raycaster.setFromMatrixWorld(controller.matrixWorld); const intersects = raycaster.intersectObjects(hotspotMeshes, false); if (intersects.length > 0) { const mesh = intersects[0].object; const targetId = mesh.userData.targetSceneId; action(targetId); } }); }鼠标命中逻辑需要额外用鼠标位置计算 NDC 坐标:
const mouse = new THREE.Vector2(); mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(hotspotMeshes, false);hotspotMeshes是场景内所有热点 Mesh 的数组。热点 Mesh 在创建时将目标场景 ID 写入userData,这样命中后直接读取userData.targetSceneId,不需要查表。这个做法保持了事件回调的轻量化。
3.3 跳转过程:预加载、销毁与异步切换
跳转不能简单粗暴地清空场景再加载纹理,否则会出现长时间黑屏。贝壳看房的体验是点击后画面轻微模糊,然后平滑过渡到新场景。常规做法是在点击时先为新场景创建纹理,等纹理加载完成后,再一次性替换球体贴图。
async function switchScene(targetSceneId) { const targetScene = sceneData.scenes.find(item => item.id === targetSceneId); if (!targetScene) return; const loader = new THREE.TextureLoader(); const nextTexture = await loader.loadAsync('/' + targetScene.panorama); nextTexture.colorSpace = THREE.SRGBColorSpace; nextTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); const transitionDuration = 0.8; const startTime = performance.now(); await new Promise(resolve => { const updateFade = () => { const currentTime = performance.now(); const progress = Math.min((currentTime - startTime) / (transitionDuration * 1000), 1); material.opacity = 1 - progress; material.transparent = true; if (progress < 1) { requestAnimationFrame(updateFade); } else { material.map = nextTexture; material.needsUpdate = true; material.opacity = 1; resolve(); } }; updateFade(); }); prevTexture.dispose(); cleanUpHotspots(); await createHotspotsForScene(targetScene); }这段代码使用了两次异步操作:第一次loadAsync加载新纹理,第二次用 fade 动画过渡。needsUpdate = true强制材质重新编译并识别新纹理。最后必须调用dispose销毁旧纹理,否则内存会随着点击次数增加而持续堆积。
3.4 相邻场景的组织方式
实战中,楼盘的全景图可能存在几十个节点。不建议把全部热点做成实体的 3D 模型,而是应该在进入某个场景时动态生成属于该场景的热点。因为热点数量不多,每场景平均 5 个左右,即使频繁切换也不会有性能问题。如果场景互相联通形成网状结构,还可以用广度优先遍历提前预加载二级相邻场景的纹理,减少跳转等待时间。参考贝壳看房的 UI,热点通常是一个带箭头的圆点,点击后可以前进返回。数据结构中保留back关联字段即可,不需要单独维护历史栈。
4. 从 zip 源码中抽取项目说明:目录结构、入口与关键参数表
4.1 典型三层目录结构
一个包含源码和项目说明的 zip 通常解压后是如下结构:
three-vr-panorama/ ├── index.html ├── package.json ├── src/ │ ├── main.js │ ├── sceneManager.js │ ├── hotspotManager.js │ ├── data/scenes.json │ └── textures/ ├── docs/ │ └── 项目说明.md └── README.mdindex.html里挂载 canvas 容器和 VR 按钮。main.js负责初始化渲染器和场景管理。sceneManager.js负责加载全景图,hotspotManager.js负责创建热点与拾取事件。数据文件单独剥离,方便非前端人员维护。项目说明文档的作用主要是说明如何在本地启动和修改配置。尽管是静态页面,仍然推荐在本地启动一个 HTTP 服务,否则等距柱状图纹理大概率会因为 CORS 问题加载失败。
4.2 本地启动与服务配置
在项目根目录执行以下命令:
npm install npm run devpackage.json中需要配置 dev 脚本。如果你没有 node_modules,另一个轻量做法是用 Python 起一个静态服务器:
python3 -m http.server 8080打开http://localhost:8080会发现页面可以正常查看。这里的关键是textures目录必须与页面处于同一主机下。如果直接双击index.html,TextureLoader加载本地文件会被浏览器的安全策略拦截。
4.3 参数调节表:这些值决定了交互手感
| 参数 | 默认值范围 | 作用 | 调试建议 |
|---|---|---|---|
SphereGeometry半径 | 300-1000 | 保证相机在内部且不穿模 | 小于 100 时会出现近裁面遮挡 |
OrbitControls.rotateSpeed | 0.3-1.0 | 鼠标拖动灵敏度 | VR 模式下此设置不生效 |
| 热点距离系数 | 0.98-0.99 | 热点放在球体内表面的径向比例 | 0.98 可防止被截断 |
| 过渡动画时长 | 0.5-1.5 秒 | 新老场景淡入淡出 | 配合requestAnimationFrame |
renderer.setPixelRatio | 1-1.5 | 控制渲染清晰度 | 移动端不要超过 1.5,否则过热 |
特别关注hotspotManager中的热点距离系数。将热点 Mesh 放在半径乘以 0.98 的位置,既能保证热点处在球体边界上,又不会因为浮点精度问题被裁剪。在 VR 模式下,用户会明显感到热点离眼睛更近,深度感知会更强。
4.4 项目说明文档的写法
项目说明不需要过分冗长,但必须回答三个问题:怎么跑起来、怎么换全景图、怎么加跳转。建议用 Markdown 写,放入docs/项目说明.md。核心段落是热点配置示例:
每个场景节点包含 `id`、`panorama` 和 `hotspots` 数组。 `hotspots` 中每一个对象的 `direction` 为热点在 3D 空间中的朝向向量, 通常会在美术定位后回填。目标场景 ID 必须存在于 `scenes` 列表中, 否则切换函数会直接 return。把这段内容与源码放一起,用户不会面对空白工程无从下手。
5. 性能与体验优化:纹理压缩、预加载和 WebXR 兼容性
5.1 全景图的分辨率与尺寸控制
一张 8000x4000 的全景图是 JPG 格式时可能超过 20MB,明显不适合网页加载。贝壳看房在网络较好时也优先加载压缩后的大图,但在弱网环境下必须提供降级。常规做法是对同一张全景图生成三档尺寸:高清、标清和缩略预览图。初始加载使用可以较低的尺寸,当场景切换时加载高清纹理并替换。
const levels = { low: '/textures/room_1_low.jpg', high: '/textures/room_1_high.jpg' }; textureLoader.load(levels.low, (texture) => { sphere.material.map = texture; sphere.material.needsUpdate = true; textureLoader.load(levels.high, (highTexture) => { sphere.material.map = highTexture; sphere.material.needsUpdate = true; }); });这里needsUpdate被设置了两次,第一次是标清纹理替换,第二次是高清纹理替换。缺点是需要准备两份纹理,但换来的体验非常值:用户先看到模糊画面,几秒后变清晰,不会出现白屏等待。
5.2 热点与场景树的预加载策略
在场景关系图中,上一级的邻近场景可以提前预加载。核心思想是在switchScene调用后,立刻请求该场景的所有邻接场景纹理和 JSON 片段。用TextureLoader.load()预加载时,不需要将结果立刻加入场景,缓存机制会自动保留。
function preloadAdjacentScenes(sceneId) { const sceneData = sceneGraph.find(s => s.id === sceneId); sceneData.hotspots.forEach(hotspot => { const neighbor = sceneGraph.find(s => s.id === hotspot.target); if (neighbor && !loadedTextures[neighbor.panorama]) { const loader = new THREE.TextureLoader(); loader.load('/' + neighbor.panorama, (texture) => { loadedTextures[neighbor.panorama] = texture; }); } }); }需要注意预加载的副作用:如果用户在很短时间内连续点击多个热点,会导致纹理请求堆积。此时应该维护一个跳转队列,只响应最后一次跳转。一般做法是在点击处理器中取消上一次未完成的跳转:if (switchTimer) clearTimeout(switchTimer)。
5.3 WebXR 移动端兼容性常见问题
在手机浏览器上,WebXR 需要 HTTPS 协议,且只支持较新的 Android Chrome 或 iOS Safari(iOS 17 支持 WebXR 有限)。具体表现为navigator.xr未定义或isSessionSupported返回 false。
if (navigator.xr && navigator.xr.isSessionSupported('immersive-vr')) { // 启用 VRButton } else { // 退化为普通全景模式 }代码中必须提供这一层降级。VR 模式下,OrbitControls不会生效,需要用camera.quaternion来响应手柄旋转。但全景场景中通常不需要额外的旋转逻辑,WebXR 原生会跟踪头显朝向,相机默认朝向由之前位置决定。因此进入 VR 后,只需要关闭OrbitControls并禁用其自动更新。
5.4 跨域与 CORS 相关问题
全景纹理如果放在 CDN 上,必须确认 CDN 返回Access-Control-Allow-Origin: *头,否则loadAsync会直接 reject。这个问题在本地开发时不容易暴露,部署到生产环境后最容易遇到。排查方法是在浏览器控制台看网络请求里的 CORS 错误。遇到这类问题,先在本地把纹理复制到textures/目录验证,再检查 CDN 配置。另外,代码中如果存在THREE.ImageUtils.crossOrigin = 'anonymous'这类旧写法,在新版本中需要删除,统一用TextureLoader的setCrossOrigin方法。
6. 最后的调试技巧:给热点加一个可视化辅助球
热点位置往往在开发时是盲调的:你只能在地面模式下用鼠标点击,但无法准确判断热点是否真的对准了门洞。一个有效的方式是在热点数据中追加debug字段,然后把热点的小圆盘替换成一个半透明的球体,同时显示一条从球心指向热点的射线。
const debugSphere = new THREE.Mesh( new THREE.SphereGeometry(10, 16, 16), new THREE.MeshBasicMaterial({ color: 0xffaa00, transparent: true, opacity: 0.6 }) ); debugSphere.position.copy(direction.clone().normalize().multiplyScalar(sphereRadius * 0.9)); scene.add(debugSphere);运行项目后,打开控制台动态修改scenes.json中热点的direction,立刻能看到辅助球变化。确认位置正确后,把debug字段置空或删除,切换回正式的热点 Mesh。
另一个排错技巧是监听当前场景 ID 的变化。在switchScene入口打印日志,利用 WeakMap 记录已加载纹理的引用,防止重复加载。节奏上,先确认数据不报错,再调交互,最后调视觉。这个顺序能避免过渡动画和热点拾取问题混合在一起,让你更快定位到是跳转逻辑出错还是热点位置偏移。
本文还有配套的精品资源,点击获取