资讯动态

Vue3+TS+Three.js轻量级全景看房系统实现

发布时间:2026/10/16 10:30:01 来源:尧图企业网站定制
简介这是一套基于Vue3TypeScriptThree.js实现的轻量级全景家居VR看房系统面向前端开发者、Web 3D初学者及房地产数字化展示需求者解决传统静态图无法提供沉浸式空间体验的问题适用于线上售楼、室内设计预览与交互式教学等场景。资源包共125个文件含90张JPG/PNG全景素材如Living.hdr、ersisd-Merksem_Appartment_Living_4k.hdr等环境贴图、16个PNG/GIF加载与UI资源、4个核心JS逻辑脚本、2个Vue组件及配套JSON配置与HTML入口整体75.54MB结构清晰开箱即用。已有557人学习下载读者可直接获取完整可运行项目包含多房间热点跳转逻辑、360°自由拖拽视角控制、HDR光照环境集成、响应式加载提示与标准化Vue3工程目录是理解Three.js在真实业务中落地全景渲染的优质实践样本。1. Vue3 TS Three.js 全景家居 VR 看房系统不是“加个 3D 模型”就叫看房而是让户型图真正“站”起来、能走、能转、能交互的轻量级落地方案你有没有遇到过这样的场景客户盯着一张静态户型图反复问“这个窗户朝哪边”“沙发放这儿会不会挡光”“主卧和卫生间之间那堵墙能敲吗”——而你只能靠比划、截图、甚至手绘草图解释。这不是沟通问题是信息维度断层。Vue3 TS Three.js 全景家居 VR 看房系统 v1.0就是为解决这个断层而生的它不依赖专业建模软件导出的庞大数据包也不需要 WebGL 黑盒渲染器兜底而是用标准立方体全景图equirectangular为输入通过 Three.js 的TextureLoaderSphereGeometryMeshBasicMaterial构建可自由旋转、缩放、平移的球面投影空间再叠加 Vue3 响应式状态管理驱动的 UI 控制层视角复位、热点标注、材质切换最终在普通笔记本浏览器里跑出 60fps 的实时交互体验。它适合中小型家装公司快速部署线上展厅、设计师向客户同步方案细节、或作为 BIM 模型轻量化预览入口。核心价值不在“炫技”而在“降维交付”——把设计师脑中的空间逻辑变成客户手指一划就能验证的真实感。2. 从一张全景图到可交互球面空间Three.js 渲染层的核心实现与选型依据2.1 为什么选球面投影而非立方体贴图——兼顾兼容性与开发效率的务实选择很多团队一上来就想上CubeCamera或WebGLRenderTarget实现动态反射但实际落地时会发现第一全景图源基本都是手机/全景相机拍的 equirectangular 格式宽高比 2:1硬转六面图不仅增加预处理步骤还容易因采样失真导致接缝第二主流全景图平台如某图、某视导出的 SDK 默认支持球面坐标系直接复用可省去坐标映射调试时间。本系统采用SphereGeometry(500, 64, 64)构建一个大球体将全景图作为纹理贴在其内表面摄像机置于球心。这样做的物理意义是人眼在球心观察球面纹理等效于在真实空间中环顾四周。500是半径足够大以避免近裁剪面穿模64是经度/纬度分段数实测低于 32 会出现明显多边形锯齿高于 128 则对低端显卡帧率影响显著。关键代码如下// src/threejs/panoramaScene.ts import * as THREE from three; export class PanoramaScene { private scene: THREE.Scene; private camera: THREE.PerspectiveCamera; private renderer: THREE.WebGLRenderer; private sphere: THREE.Mesh; constructor(container: HTMLElement) { this.scene new THREE.Scene(); this.camera new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); this.renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.renderer.setSize(window.innerWidth, window.innerHeight); container.appendChild(this.renderer.domElement); // 创建球体几何体半径500经度64段纬度64段 const geometry new THREE.SphereGeometry(500, 64, 64); // 关键翻转法线使纹理显示在球体内侧 geometry.scale(-1, 1, 1); const textureLoader new THREE.TextureLoader(); const texture textureLoader.load(/assets/pano.jpg); // 全景图路径 const material new THREE.MeshBasicMaterial({ map: texture, side: THREE.BackSide // 必须设为BackSide否则纹理在球外侧 }); this.sphere new THREE.Mesh(geometry, material); this.scene.add(this.sphere); this.camera.position.z 1; // 摄像机置于球心z0但需微调避免深度冲突 } render() { this.renderer.render(this.scene, this.camera); } }注意geometry.scale(-1, 1, 1)是翻转球面法线的关键操作。若不翻转纹理会显示在球体外侧用户看到的是“球壳外面”的倒置世界翻转后side: THREE.BackSide才能让摄像机在球心正确读取内表面纹理。这是 Three.js 球面全景最易踩的“方向玄学”坑新手常在此卡半天。2.2 视角控制用 OrbitControls 实现自然拖拽但必须禁用缩放与平移原生OrbitControls支持旋转、缩放、平移三合一但用在全景看房里会破坏空间一致性缩放会让用户误以为“拉近墙壁”实则只是改变球体观察距离失去真实尺度感平移则直接让球体“漂移”导致视角错乱。因此必须定制化禁用这两项并重写旋转逻辑使其符合人眼直觉水平拖拽左右转头垂直拖拽抬头低头。代码改造如下// src/threejs/controls/PanoramaControls.ts import { OrbitControls } from three/examples/jsm/controls/OrbitControls; export class PanoramaControls extends OrbitControls { constructor(object: THREE.Camera, domElement: HTMLElement) { super(object, domElement); // 禁用缩放与平移 this.enableZoom false; this.enablePan false; // 限制俯仰角防止看到球顶/球底破洞-85° ~ 85° 是人体自然活动范围 this.minPolarAngle Math.PI / 180 * 35; // 35°避免看到头顶天花板接缝 this.maxPolarAngle Math.PI / 180 * 145; // 145°避免看到脚下地板接缝 // 阻尼惯性提升拖拽手感 this.enableDamping true; this.dampingFactor 0.05; // 关键重写 update 方法强制摄像机始终位于球心0,0,0 const originalUpdate this.update.bind(this); this.update () { // 重置摄像机位置到球心只保留旋转矩阵 this.object.position.set(0, 0, 0); this.object.lookAt(0, 0, 0); originalUpdate(); }; } }这段代码的实质是把 OrbitControls 从“控制物体运动”降级为“纯旋转控制器”。每次update()都强制将摄像机拉回(0,0,0)确保无论用户如何拖拽观察点永远在球心——这才是全景图物理模型的正确打开方式。若跳过此步用户拖拽后摄像机会偏离球心导致纹理拉伸变形尤其在边缘区域出现严重色散。2.3 Vue3 响应式桥接用 ref 管理 Three.js 状态避免内存泄漏Three.js 对象Scene、Camera、Renderer是纯 JS 实例不自带响应式。若直接在setup()中创建并赋值给refVue 会尝试对其做 Proxy 代理反而引发不可预测行为。正确做法是用shallowRef包裹 Three.js 实例仅对需要触发 DOM 更新的状态如当前视角角度、是否加载完成使用ref。同时必须在组件卸载时手动 dispose 资源!-- src/components/PanoramaViewer.vue -- script setup langts import { onMounted, onUnmounted, shallowRef, ref } from vue; import { PanoramaScene } from /threejs/panoramaScene; import { PanoramaControls } from /threejs/controls/PanoramaControls; const container shallowRefHTMLElement | null(null); const panoramaScene shallowRefPanoramaScene | null(null); const controls shallowRefPanoramaControls | null(null); const isLoading ref(true); // 响应式状态控制 loading UI const currentYaw ref(0); // 水平视角度 const currentPitch ref(0); // 垂直视角度 onMounted(() { if (!container.value) return; // 初始化场景 panoramaScene.value new PanoramaScene(container.value); // 初始化控制器 controls.value new PanoramaControls( panoramaScene.value.getCamera(), container.value ); // 监听控制器更新同步 Vue 状态 controls.value.addEventListener(change, () { const euler panoramaScene.value?.getCamera().rotation.clone().toEuler(); currentYaw.value THREE.MathUtils.radToDeg(euler.y); currentPitch.value THREE.MathUtils.radToDeg(euler.x); }); // 启动渲染循环 const animate () { requestAnimationFrame(animate); controls.value?.update(); panoramaScene.value?.render(); }; animate(); isLoading.value false; }); onUnmounted(() { // 必须手动清理销毁 renderer、释放纹理、移除事件监听 if (panoramaScene.value) { panoramaScene.value.dispose(); // 自定义 dispose 方法见下文 } if (controls.value) { controls.value.dispose(); } }); /scriptshallowRef的作用是告诉 Vue“别管这个对象内部我只关心它本身是否被替换”。而panoramaScene.value.dispose()是在PanoramaScene类中定义的清理方法内部调用renderer.dispose()、texture.dispose()、geometry.dispose()这是 Three.js 内存管理的铁律——不显式释放页面停留越久GPU 显存占用越高最终触发浏览器崩溃。3. Vue3 组件化封装构建可复用的全景看房 UI 层与交互逻辑3.1 热点标注系统用 Canvas 覆盖层实现像素级定位与响应式缩放Three.js 场景内添加 3D 文字或 Sprite 会带来额外渲染开销且在球面投影下定位复杂。更轻量的做法是在 Three.jscanvas上方叠加一层透明canvas用 2D Canvas 绘制热点图标与文字并通过camera.project()将 3D 空间坐标如某扇窗的经纬度实时转换为屏幕像素坐标。本系统定义热点数据结构为// src/types/panorama.ts export interface Hotspot { id: string; name: string; description: string; lng: number; // 经度 -180~180 lat: number; // 纬度 -90~90 type: door | window | furniture | info; }核心转换逻辑在HotspotManager类中实现// src/threejs/hotspots/HotspotManager.ts import * as THREE from three; export class HotspotManager { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private hotspots: Hotspot[] []; constructor(canvas: HTMLCanvasElement) { this.canvas canvas; this.ctx canvas.getContext(2d)!; } // 将经纬度转换为归一化设备坐标NDC-1~1 private lngLatToNDC(lng: number, lat: number): THREE.Vector3 { // 球面坐标转笛卡尔坐标单位球 const phi THREE.MathUtils.degToRad(90 - lat); // 极角 const theta THREE.MathUtils.degToRad(lng); // 方位角 const x Math.sin(phi) * Math.cos(theta); const y Math.cos(phi); const z Math.sin(phi) * Math.sin(theta); return new THREE.Vector3(x, y, z); } // 将 NDC 转为屏幕像素坐标 update(camera: THREE.Camera, renderer: THREE.WebGLRenderer) { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.hotspots.forEach(hotspot { const ndc this.lngLatToNDC(hotspot.lng, hotspot.lat); const screenPos ndc.clone().project(camera); // project 到 NDC // NDC (-1~1) 转屏幕像素 const x (screenPos.x * 0.5 0.5) * this.canvas.width; const y (-screenPos.y * 0.5 0.5) * this.canvas.height; // 只绘制在视锥内的热点Z 0 表示在摄像机前方 if (screenPos.z 0) { this.drawHotspotIcon(x, y, hotspot); } }); } private drawHotspotIcon(x: number, y: number, hotspot: Hotspot) { this.ctx.font 14px sans-serif; this.ctx.fillStyle #fff; this.ctx.textAlign center; this.ctx.textBaseline middle; // 绘制圆点 this.ctx.beginPath(); this.ctx.arc(x, y, 8, 0, Math.PI * 2); this.ctx.fillStyle this.getHotspotColor(hotspot.type); this.ctx.fill(); // 绘制文字标签带背景遮罩提升可读性 const text hotspot.name; const metrics this.ctx.measureText(text); this.ctx.fillStyle rgba(0,0,0,0.7); this.ctx.fillRect(x - metrics.width/2 - 4, y - 10, metrics.width 8, 20); this.ctx.fillStyle #fff; this.ctx.fillText(text, x, y); } }提示screenPos.z 0是关键过滤条件。若不加此判断当用户视角背对热点时该点会被投影到屏幕另一侧负 Z导致图标“穿墙”出现在错误位置。这是全景标注中最隐蔽的视觉 bug必须用 Z 深度剔除。3.2 材质切换与光照模拟用 Three.js 材质系统实现“白天/夜晚”模式真实看房需模拟不同时段光照效果。本系统不引入复杂光照计算如 HemisphereLight而是通过切换MeshBasicMaterial的color和emissive属性配合环境光强度调节实现低成本氛围切换// src/composables/useLighting.ts import { ref, watch } from vue; import * as THREE from three; export function useLighting(sphereMesh: THREE.Mesh) { const mode refday | night(day); const dayParams { color: 0xffffff, emissive: 0x333333, ambientIntensity: 0.8 }; const nightParams { color: 0x444444, emissive: 0x8888ff, ambientIntensity: 0.3 }; const applyMode (params: typeof dayParams) { if (sphereMesh.material instanceof THREE.MeshBasicMaterial) { sphereMesh.material.color.set(params.color); sphereMesh.material.emissive.set(params.emissive); } }; watch(mode, (newMode) { applyMode(newMode day ? dayParams : nightParams); }); return { mode }; }MeshBasicMaterial无光照计算完全依赖color基础色与emissive自发光色混合。白天模式提高color亮度、降低emissive模拟自然光主导夜晚模式压低color、提升蓝紫色emissive模拟室内灯光补光。这种方案在低端设备上帧率无损且效果直观——用户一眼就能分辨“这是白天看还是晚上看”。3.3 响应式布局适配Canvas 尺寸同步与 DPR 修正Three.js 渲染器尺寸必须与 DOM 容器像素尺寸严格一致否则出现模糊、拉伸或黑边。尤其在高 DPI 设备MacBook Retina、安卓旗舰机上window.devicePixelRatio大于 1若不修正Canvas 会以 CSS 像素渲染导致图像发虚。解决方案是// src/threejs/utils/resizeHandler.ts export function setupResizeHandler( renderer: THREE.WebGLRenderer, container: HTMLElement, onResize?: () void ) { const handleResize () { const width container.clientWidth; const height container.clientHeight; const dpr window.devicePixelRatio || 1; // 设置 canvas 实际像素尺寸考虑 DPR renderer.setSize(width * dpr, height * dpr, false); // 设置 CSS 显示尺寸保持容器比例 container.style.width ${width}px; container.style.height ${height}px; // 更新相机宽高比 if (renderer.camera instanceof THREE.PerspectiveCamera) { renderer.camera.aspect width / height; renderer.camera.updateProjectionMatrix(); } onResize?.(); }; window.addEventListener(resize, handleResize); handleResize(); // 初始化执行一次 return () window.removeEventListener(resize, handleResize); }renderer.setSize(width * dpr, height * dpr, false)中的false参数至关重要它表示“不要自动设置 canvas.style”从而允许我们手动控制 CSS 尺寸避免浏览器二次缩放。这是高 DPI 设备上保证全景图锐利度的唯一可靠方式。4. 避坑指南全景看房系统开发中高频踩坑与血泪排查记录4.1 现象全景图加载后黑屏控制台无报错原因全景图 URL 路径错误或跨域限制。Three.js 的TextureLoader加载失败时默认静默不会抛异常仅在控制台输出警告如THREE.WebGLRenderer: Texture is not power of two但若图源 404则整个纹理为空白黑色。解决在TextureLoader.load()中添加onError回调并主动检查 HTTP 状态码textureLoader.load( /assets/pano.jpg, (texture) { console.log(全景图加载成功); }, undefined, (err) { console.error(全景图加载失败:, err); // 这里可 fallback 到占位图或报错 UI } );同时确认 Webpack/Vite 静态资源路径配置正确.jpg文件确实在dist/assets/下存在。4.2 现象拖拽旋转时画面撕裂、卡顿FPS 不足 30原因未启用requestAnimationFrame的正确节流或OrbitControls.update()被重复调用。常见错误是在mounted中写setInterval(() controls.update(), 16)这会导致控制逻辑与渲染循环不同步且无法利用浏览器垂直同步VSync。解决严格遵循 Three.js 官方动画循环范式controls.update()必须放在requestAnimationFrame回调内且仅调用一次const animate () { requestAnimationFrame(animate); controls.value?.update(); // 仅此处调用 panoramaScene.value?.render(); }; animate();此外检查OrbitControls.dampingFactor是否过大0.1过大会导致拖拽粘滞过小0.02则惯性不足手感生硬。4.3 现象热点图标在快速旋转时“抖动”或位置偏移原因Canvas 坐标计算未考虑devicePixelRatio。当dpr2时Canvas 实际像素是 CSS 像素的 2 倍但getBoundingClientRect()返回的是 CSS 像素若直接用其宽高算缩放比会导致坐标映射偏差。解决在HotspotManager.update()中统一使用canvas.width/height即实际像素尺寸进行坐标换算而非container.clientWidth/clientHeight// 错误用 clientWidth 计算 // const x (screenPos.x * 0.5 0.5) * container.clientWidth; // 正确用 canvas 实际像素宽度 const x (screenPos.x * 0.5 0.5) * this.canvas.width;4.4 现象切换路由后返回全景页画面黑屏或纹理丢失原因Vue 组件复用机制导致onUnmounted未正确触发Three.js 资源未释放新实例初始化时因 GPU 上下文冲突而失败。尤其在 Vue Router 的keep-alive缓存下高发。解决在onBeforeUnmount中强制清理并添加key强制组件重建template PanoramaViewer :keypanoId / /template script setup const panoId ref(1); // 切换全景图时更新 key const loadNewPano (id: number) { panoId.value id; }; /script同时在PanoramaScene.dispose()中加入renderer.forceContextLoss()谨慎使用或确保texture.dispose()、geometry.dispose()执行完毕。4.5 现象移动端双指缩放误触发浏览器页面缩放导致全景页整体变小原因浏览器默认允许用户双指缩放页面干扰了 Three.js 的触摸控制。解决在全景容器上添加touch-action: noneCSS并禁用全局缩放#panorama-container { touch-action: none; /* 禁用浏览器默认触摸行为 */ user-select: none; } /* 在 index.html head 中添加 */ meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno注意touch-action: none会禁用所有手势因此必须确保OrbitControls的enableRotate在移动端已正确绑定touchstart/touchmove事件。5. 进阶技巧用 GLTF 模型叠加增强现实感及性能优化实战5.1 在球面全景中精准叠加 3D 家具模型坐标系对齐与锚点定位纯全景图缺乏深度信息用户难以判断“这张沙发离我有多远”。引入 GLTF 模型如沙发、茶几可提供真实尺度参考。难点在于GLTF 模型坐标系Y-up与全景球坐标系Y-up但原点在球心需对齐且模型需“钉”在球面某点如客厅中心上。实现分三步第一步确定锚点经纬度用HotspotManager.lngLatToNDC()获取目标点的单位球坐标ndc即模型应放置的位置向量。第二步创建空组节点将模型父级于此const anchorGroup new THREE.Group(); anchorGroup.position.copy(ndc); // 位置设为球面点 anchorGroup.scale.multiplyScalar(500); // 拉伸到球面半径距离 scene.add(anchorGroup); // 加载 GLTF 模型 gltfLoader.load(/models/sofa.gltf, (gltf) { const model gltf.scene; model.position.set(0, 0, 0); // 模型自身原点在锚点处 model.rotation.order YXZ; // 避免万向节死锁 anchorGroup.add(model); });第三步动态调整模型朝向使其“面向”摄像机// 在渲染循环中执行 model.lookAt(camera.position); // 始终朝向摄像机模拟“悬浮”效果这样叠加的模型不会随球面旋转而“滑动”而是稳定钉在指定经纬度且始终正对用户极大增强空间真实感。5.2 性能监控与瓶颈定位用 Three.js 的 Stats.js 与 Chrome DevTools 双轨分析全景系统性能瓶颈常隐藏在纹理上传、Shader 编译或 CPU-GPU 同步上。必须建立量化监控监控项工具健康阈值优化手段FPSstats.js≥55 fps降低SphereGeometry分段数禁用antialias移动端GPU MemoryChromechrome://gpu 300MB调用texture.dispose()压缩全景图至 4096×2048 以内Shader Compile TimeChrome DevTools → Rendering → FPS Meter 50ms避免运行时动态创建 Material预编译 ShaderDraw Callsrenderer.info.render.calls 5合并静态模型禁用MeshStandardMaterial改用MeshBasicMaterial在main.ts中注入 Statsimport * as THREE from three; import { Stats } from three/examples/jsm/libs/stats.module; const stats new Stats(); document.body.appendChild(stats.dom); // 在渲染循环中调用 const animate () { requestAnimationFrame(animate); stats.update(); // ...其他逻辑 };5.3 移动端手势增强用 Hammer.js 补全 Three.js 缺失的 pinch-in/pinch-out 手势Three.js 的OrbitControls在移动端仅支持单指旋转、双指平移但用户直觉是“双指放大看细节”。需用 Hammer.js 拦截原生事件映射为camera.fov调整// src/threejs/mobileGestures.ts import * as Hammer from hammerjs; export function setupMobileGestures( container: HTMLElement, camera: THREE.PerspectiveCamera, controls: PanoramaControls ) { const hammer new Hammer(container); // 双指缩放映射为 FOV 调整非真实缩放避免破坏球面模型 hammer.get(pinch).set({ enable: true }); hammer.on(pinch, (ev) { const delta ev.scale - 1; camera.fov Math.max(40, Math.min(90, camera.fov - delta * 10)); camera.updateProjectionMatrix(); }); // 长按触发热点详情替代点击防误触 hammer.on(press, (ev) { // 调用 HotspotManager 的 hitTest 方法获取最近热点 }); }camera.fov调整是伪缩放FOV 变小如 40°模拟“拉近”变大90°模拟“拉远”不改变摄像机位置因此不破坏球面投影的物理一致性。这是移动端体验升级的关键一环。从那以后我每次交付全景看房项目都会强制走一遍「四步压力测试」① 用 Chrome DevTools 的 Rendering 面板开启 FPS Meter拖拽满帧② 在 iPhone Safari 上双指缩放十次确认无页面缩放③ 切换三个不同分辨率全景图检查内存不持续增长④ 关闭所有热点用renderer.info确认 draw calls ≤ 3。这四步筛掉 90% 的线上事故。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑