简介:这是一套基于 Vue3、TypeScript 与 Three.js 构建的全景家居 VR 看房系统源码,面向具备一定前端基础、希望学习全景交互开发的开发者,以及需要快速搭建房产展示原型的团队。系统支持 360° 全景自由拖拽旋转,并通过房间名称热点标注实现多场景切换,帮助读者理解全景图拼接、HDR 环境光照与视角控制等核心实现思路。压缩包共 125 个文件,以 90 张 jpg 与 16 张 png 全景贴图及界面素材为主,另含 3 个 hdr 环境贴图、2 个 vue 组件、4 个 js 脚本与若干 json 配置,整体约 75.54MB,素材与代码分层清晰,便于按模块查阅。目前已有 558 人学习下载,适合作为全景看房、室内设计展示类项目的参考实现,也可直接在此基础上替换贴图与热点数据,快速改造成自有房源展示页面。
1. 全景看房不是放个 360 度图片就完事
很多同学接到「全景家居 VR 看房」这类毕设题目,第一反应是找一张全景图,套个球体,鼠标拖一拖就交差。真做起来才发现,需求方要的是「能换房间、能看家具、能标尺寸、能分享链接」的一整套看房流程,而不是一张会转的图。这套基于 Vue3 + TypeScript + Three.js 的全景家居 VR 看房系统 v1.0,解决的就是从房源列表到单房间全景漫游、再到热点交互与户型切换的完整链路。它适合两类人:正在做毕设或课程设计、需要一套结构完整可交付方案的学生;以及要快速搭一个全景展示 Demo 验证交互逻辑的前端从业者。全景看房这个场景,难点从来不在「渲染一个球」,而在状态管理、热点坐标换算和多场景切换的工程化组织。
2. 需求拆解与技术选型:为什么是 Vue3 + TS + Three.js 这套组合
动手写代码之前,先把「看房」这件事拆成可交付的功能单元,再决定每个单元用什么技术兜底。这一步做扎实,后面写起来才不会边写边推翻。
2.1 从看房流程反推功能清单
一个合格的全景看房系统,用户路径通常是:进入房源列表 → 选中一套房 → 加载该房源的首个全景场景 → 拖动环视 → 点击地面热点切换到下一个房间 → 查看房间信息或家具标注 → 返回列表换房。把这条路径拆开,功能清单大致是这几块:
| 模块 | 核心职责 | 交付物 |
|---|---|---|
| 房源管理 | 房源列表、封面、场景配置 | 房源数据结构 + 列表页 |
| 全景渲染 | 球体/立方体贴图渲染、相机控制 | Three.js 场景封装 |
| 场景切换 | 多房间全景图切换、过渡 | 场景管理器 |
| 热点交互 | 地面/墙面热点、点击跳转、信息弹窗 | 热点组件 + 坐标换算 |
| 状态管理 | 当前房源、当前场景、加载状态 | Pinia store |
| 响应式适配 | 窗口缩放、移动端触摸 | resize 与控制器配置 |
这张表不是摆设,它直接决定了目录结构和后续每个模块的边界。我一般会先把这张表定下来,再开始建目录,避免写到一半发现「热点」和「场景切换」耦合在一起拆不开。
2.2 技术选型的三条硬理由
选 Vue3 + TypeScript + Three.js,不是跟风,是三个具体问题逼出来的。
第一,Three.js 的场景对象生命周期很长,相机、渲染器、控制器、纹理都要手动销毁,用组合式 API 把它包成usePanorama这样的 composable,挂载时初始化、卸载时dispose,比选项式写法清晰得多。第二,全景项目里坐标、角度、场景 ID 满天飞,TypeScript 的接口约束能挡掉大量「传错字段」的低级错误,尤其是热点坐标这种{ x, y, z }结构,类型一写,编辑器直接提示。第三,Vue 的响应式系统和 Three.js 的渲染循环是两套体系,必须明确分工:Three.js 管渲染,Vue 管 UI 和状态,两者通过 store 和事件桥接,不能互相侵入。
提示:不要把 Three.js 的对象塞进
reactive,深层代理会拖慢渲染甚至引发难以排查的异常。场景对象用shallowRef或普通变量持有,只把「当前场景 ID」「加载进度」这类原始值放进响应式状态。
2.3 版本与依赖的取舍
依赖不需要多,够用就行。核心就三个:three负责渲染,vue负责视图层,pinia负责跨组件状态。相机控制器直接用 Three.js 自带的OrbitControls,把旋转中心锁在球心、禁用平移和缩放,就能得到标准的全景环视手感,没必要自己写一套鼠标事件。构建工具用 Vite,热更新快,调试全景时改一行材质参数立刻能看到效果,这个体验差距在反复调参阶段非常明显。
# 核心依赖,版本以项目 package.json 为准 npm install three vue pinia npm install -D typescript vite @types/three@types/three必须装,否则 TS 里所有 Three.js 类型都是any,等于白用 TypeScript。装完之后在tsconfig.json里确认strict打开,热点坐标、场景配置这些结构体才有约束力。
3. 总体架构设计:渲染层、状态层与视图层的职责划分
架构这件事,说白了就是回答「谁负责什么、谁不能碰谁」。全景看房最容易翻车的地方,就是渲染逻辑和业务逻辑搅在一起,改一个热点跳转要动三个文件。
3.1 三层结构与时序关系
整个系统按三层组织:视图层(Vue 组件)负责页面和交互 UI;状态层(Pinia)持有当前房源、当前场景、热点列表等业务数据;渲染层(Three.js 封装)只认「给我一张全景图和一个容器」,渲染完把点击事件抛回来。三层的时序是:视图层触发「切换场景」→ 状态层更新currentSceneId→ 渲染层监听到变化 → 加载新纹理并重建球体材质 → 渲染完成回调状态层清除 loading。
这个单向数据流是关键。渲染层不直接改业务状态,只通过回调上报;状态层不碰 Three.js 对象,只存原始数据。这样调试时,任何异常都能定位到「是数据错了还是渲染错了」,而不是一团乱麻。
3.2 目录结构约定
src/ components/ PanoramaViewer.vue # 全景容器组件 HotspotLayer.vue # 热点覆盖层 composables/ usePanorama.ts # Three.js 场景封装 stores/ houseStore.ts # 房源与场景状态 types/ panorama.d.ts # 类型定义 views/ HouseList.vue # 房源列表 HouseDetail.vue # 看房主页面PanoramaViewer.vue只负责挂载容器和调用 composable,usePanorama.ts里才是真正的 Three.js 代码。这种拆法的好处是:Three.js 逻辑可以脱离 Vue 单独测试,组件也保持轻薄。
3.3 核心类型定义先行
TypeScript 项目里,类型定义是最便宜的设计文档。先把数据结构定死,后面所有模块都围绕它写。
// types/panorama.d.ts export interface Hotspot { id: string; /** 热点在球面上的球坐标角度,单位弧度 */ theta: number; phi: number; /** 跳转目标场景,为空表示纯信息热点 */ targetSceneId?: string; label: string; } export interface PanoramaScene { id: string; name: string; /** 全景图地址,建议 2:1 等距柱状投影 */ imageUrl: string; hotspots: Hotspot[]; } export interface House { id: string; title: string; cover: string; scenes: PanoramaScene[]; }热点坐标用球坐标theta/phi而不是三维笛卡尔坐标,是因为全景球上的位置本质就是经纬度,用角度存、渲染时再换算成三维点,既直观又不会因为球体半径变化而失效。这个选择在后期加热点编辑器时会省很多事。
4. 全景渲染与热点交互实现:从球体贴图到点击跳转
这一章是整套方案的技术核心,渲染和交互的细节都在这里。写得好不好,直接决定拖动是否跟手、热点是否对得准。
4.1 球体全景的初始化与销毁
全景渲染的原理是把等距柱状投影图贴到一个内表面可见的球体上,相机放在球心。球体半径取 500 左右即可,太小会有明显畸变,太大则深度精度浪费。材质用MeshBasicMaterial,不需要光照,全景图本身就是「发光」的。
// composables/usePanorama.ts(节选) import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; export function usePanorama(container: HTMLElement) { const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000); camera.position.set(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); container.appendChild(renderer.domElement); const geometry = new THREE.SphereGeometry(500, 60, 40); geometry.scale(-1, 1, 1); // 翻转法线,让相机看到球体内表面 const material = new THREE.MeshBasicMaterial(); const sphere = new THREE.Mesh(geometry, material); scene.add(sphere); const controls = new OrbitControls(camera, renderer.domElement); controls.enableZoom = false; controls.enablePan = false; controls.rotateSpeed = -0.3; // 负值让拖动方向符合直觉 // 销毁时必须逐项释放,否则切换场景会持续泄漏显存 function dispose() { geometry.dispose(); material.map?.dispose(); material.dispose(); renderer.dispose(); controls.dispose(); container.removeChild(renderer.domElement); } return { scene, camera, renderer, controls, sphere, material, dispose }; }几个参数值得说清楚:geometry.scale(-1, 1, 1)是全景渲染的经典操作,翻转法线后相机才能看到球体内壁;rotateSpeed设负值是因为 OrbitControls 默认拖动方向和「抓住画面拖」的直觉相反,负值更符合看房手感;setPixelRatio上限压到 2,避免高分屏上渲染压力翻倍。dispose里每一项都要释放,血泪经验是漏掉material.map的释放,来回切十几个场景后页面直接卡死。
4.2 纹理加载与场景切换
切换场景本质是换material.map。加载新图时先保留旧图,等新纹理onLoad后再替换,避免中间出现黑屏。
const loader = new THREE.TextureLoader(); function loadScene(imageUrl: string): Promise<void> { return new Promise((resolve, reject) => { loader.load( imageUrl, (texture) => { texture.colorSpace = THREE.SRGBColorSpace; // 避免全景图发灰 const old = material.map; material.map = texture; material.needsUpdate = true; old?.dispose(); // 新图就位后再释放旧图 resolve(); }, undefined, reject ); }); }colorSpace这一行很容易被忽略,不设的话全景图整体偏灰偏暗,调半天材质都救不回来。加载失败要reject出去,让上层显示「图片加载失败,点击重试」,而不是让用户对着黑屏发呆。
4.3 热点坐标换算与点击拾取
热点有两种实现思路:用 Three.js 的 Sprite 放进三维场景,或者用 HTML 元素叠在 canvas 上。前者跟随旋转自然,后者样式好写、事件好绑。我一般选 HTML 覆盖层,因为热点要显示文字标签和弹窗,用 DOM 做省事得多。
关键是把球坐标换算成屏幕坐标。每帧渲染后,把热点方向上的三维点投影到相机平面:
function projectHotspot(theta: number, phi: number): { x: number; y: number } { const r = 480; // 略小于球体半径,避免被球面遮挡 const point = new THREE.Vector3( r * Math.sin(phi) * Math.cos(theta), r * Math.cos(phi), r * Math.sin(phi) * Math.sin(theta) ); point.project(camera); // 归一化设备坐标 return { x: (point.x * 0.5 + 0.5) * container.clientWidth, y: (-point.y * 0.5 + 0.5) * container.clientHeight, }; }投影后还要判断热点是否在相机背后:point.z > 1时说明在视野外,应该隐藏。这个判断不做,转到背面时热点会「镜像」出现在错误位置,是全景项目里最常见的翻车点之一。热点位置在渲染循环里更新,用requestAnimationFrame驱动,和 Three.js 的渲染节奏保持一致。
4.4 状态层与渲染层的桥接
Pinia store 只存原始数据,渲染层通过watch监听变化:
// stores/houseStore.ts(节选) export const useHouseStore = defineStore('house', () => { const currentHouse = ref<House | null>(null); const currentSceneId = ref<string>(''); const loading = ref(false); const currentScene = computed(() => currentHouse.value?.scenes.find((s) => s.id === currentSceneId.value) ); function switchScene(id: string) { if (id === currentSceneId.value) return; loading.value = true; currentSceneId.value = id; } return { currentHouse, currentSceneId, currentScene, loading, switchScene }; });组件里watch(() => store.currentScene?.imageUrl, url => url && loadScene(url)),加载完把loading置回 false。这样「点热点 → 改 store → 触发加载 → 更新 UI」整条链路是单向的,任何一环出问题都能单独排查。
5. 避坑与常见问题排查:全景项目里最容易翻车的五个点
这一章按「现象 → 原因 → 解决」写,都是实际做下来反复遇到的,提前知道能省好几天。
5.1 拖动方向反了或上下颠倒
现象:鼠标往右拖,画面往左转;或者上下方向完全反。原因:OrbitControls 的默认旋转方向和「抓住场景拖」的直觉相反,且全景球翻转法线后坐标系也跟着变。解决:把rotateSpeed设为负值,同时确认geometry.scale(-1, 1, 1)只翻转了 X 轴,不要三个轴都翻,否则上下会颠倒。
5.2 切换场景后越来越卡
现象:来回切十几个房间后帧率明显下降,最后卡死。原因:每次加载新纹理都new TextureLoader或没释放旧纹理,显存持续累积。解决:全局复用一个 loader,替换material.map后对旧纹理调dispose(),组件卸载时把几何体、材质、渲染器全部释放。判断是否泄漏,可以在浏览器开发者工具里看显存占用是否随切换次数单调上升。
5.3 热点位置对不准
现象:热点标签在某个角度看着对,转一圈就偏了。原因:球坐标和笛卡尔坐标的换算公式写错,或者投影后没做「相机背后隐藏」判断。解决:统一用一套换算函数,theta从 X 轴起算、phi从 Y 轴起算,写死在一个工具函数里全项目复用;投影后加point.z > 1的可见性判断。
5.4 移动端触摸拖动失效
现象:桌面端正常,手机上拖不动或页面跟着滚动。原因:canvas 没设touch-action: none,浏览器把触摸手势当成页面滚动。解决:给渲染容器加touch-action: none,同时 OrbitControls 本身支持触摸,不需要额外写事件,但要在resize时同步更新相机宽高比。
5.5 全景图发灰、边缘有接缝
现象:画面整体偏暗偏灰,或者球体接缝处有一条明显竖线。原因:纹理色彩空间没设成 sRGB;全景图不是严格的 2:1 等距柱状投影,左右边缘对不上。解决:加载后设texture.colorSpace = THREE.SRGBColorSpace;素材阶段就要求 2:1 比例,接缝问题在图片层面解决,代码层面救不回来。
注意:全景图素材质量决定上限。分辨率低于 4096×2048 在桌面大屏上会明显糊,这个不是调参数能补的,选素材时就要卡住。
6. 验收与答辩:怎么证明这套看房系统真的能用
验收这件事,很多人栽在「功能都做了,但说不清做得好不好」。答辩现场最有效的做法,是拿一张验收清单逐项过,让评委看到你是按工程标准交付的,而不是拼凑了一个能跑的 Demo。
我一般会准备这样一张表,现场演示时按顺序走一遍:
| 验收项 | 合格标准 | 演示动作 |
|---|---|---|
| 全景渲染 | 画面清晰、无接缝、色彩正常 | 全屏环视一周 |
| 拖动手感 | 方向符合直觉、无卡顿 | 快速左右拖动 |
| 场景切换 | 切换无黑屏、加载有提示 | 连点三个房间热点 |
| 热点准确 | 各角度位置稳定、背面隐藏 | 转一整圈观察热点 |
| 响应式 | 窗口缩放不变形 | 拖动改变窗口大小 |
| 资源释放 | 反复切换不卡顿 | 来回切十次看帧率 |
| 异常处理 | 图片失败有提示可重试 | 断网或改错图片地址 |
答辩陈述的节奏建议是:先用一句话说清「解决什么问题」,再演示主流程,最后主动讲一个你踩过的坑和怎么解决的。主动暴露问题比被问出来强得多,比如「热点投影一开始没做背面判断,转到后面会镜像,后来加了 z 判断」——这种细节评委一听就知道是真做过。
代码层面,答辩前把dispose逻辑和类型定义翻出来讲,这两块最能体现工程素养。很多同学的全景项目死在「能跑但不敢改」,而类型完整、资源释放干净的项目,评委追问时你答得上来,分数自然不一样。
从那以后我每次交付带 Three.js 的项目,都强制走一遍「切换十次看显存、缩放三次看变形、断网一次看提示」这三步,没走完不提交。希望这套结构和清单能帮到你,照着改改就能变成你自己的方案。
本文还有配套的精品资源,点击获取