ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue3+Three.js轻量VR看房系统实战:全景交互、跨房间跳转与PBR渲染

Vue3+Three.js轻量VR看房系统实战:全景交互、跨房间跳转与PBR渲染

简介:这是一套基于Vue3+TypeScript+Three.js开发的轻量级全景家居VR看房系统,面向前端开发者、Web 3D初学者及房地产数字化展示需求者,解决传统静态户型图沉浸感弱、交互性差的问题。资源包共125个文件,含90张全景场景JPG素材(如Living.hdr、ersisd-Merksem_Appartment_Living_4k.hdr等)、16个PNG资源(含loading.gif、map.gif等UI与动效素材)、4个核心JS逻辑文件、2个Vue组件及配套JSON配置、HDR环境贴图与HTML入口等,完整支撑360°自由视角漫游与房间热点跳转功能,压缩包大小75.54MB。已有557人学习下载,提供开箱即用的本地运行方案(npm install + npm run serve),包含真实家居场景HDR光照、多房间热点标注实现逻辑、响应式全景渲染流程及基础TS类型定义,是理解Three.js全景渲染集成、Vue3组合式API与WebGL实践结合的优质入门项目。

1. 这不是PPT式“全景图”,而是一个能真实拖拽旋转、点击切换房间、带光照计算和材质反射的可交互VR看房系统

上周帮某地产科技团队做技术评估,他们拿来的“Vue3+Three.js全景看房”Demo,第一眼以为又是那种用<img>套transform: rotateY()做的伪3D——结果我随手拖拽视角,镜头居然绕着客厅中心平滑转动,鼠标悬停在沙发边缘时,实时反射出窗外的树影;点一下厨房门框,视角瞬间平移+淡入新空间,连橱柜把手的金属高光都跟着光源角度变化。这才意识到:这根本不是静态全景图轮播,而是基于Three.js R152+WebGL2构建的轻量级VR空间引擎,Vue3只负责UI编排和状态调度,真正扛住60fps渲染的是底层场景图(Scene Graph)+ PBR材质管线。它适合两类人:一是想快速交付中小户型VR看房功能的前端团队(不用搭Unity流水线),二是Three.js初学者想啃下“从加载全景图→绑定交互→跨空间跳转→性能优化”全链路的实战项目。v1.0已跑通主流Chrome/Firefox/Edge,手机端适配到iOS 15+/Android 12+,但暂未接入WebXR——这点后面会重点讲怎么补。


2. 技术栈与架构设计:Vue3是指挥官,Three.js是特种兵,状态管理是作战地图

这个项目的技术选型不是堆砌热门词,而是每层都解决一个具体问题。Vue3 Composition API负责把“用户点击哪个房间按钮”“当前是否在拖拽中”“光照强度滑块值”这些离散操作聚合成可预测的状态流;Three.js不直接暴露底层WebGL调用,而是通过OrbitControls、PMREMGenerator、GLTFLoader等高层封装,让开发者专注空间逻辑而非GPU寄存器配置;而Pinia作为状态管理器,其store结构直接映射物理空间关系——比如roomStore里每个房间对象不仅含ID和名称,还预存了该房间全景图的texture引用、预烘焙的光照贴图envMap、以及与其他房间的doorwayConnections数组。这种设计让“从客厅走到卧室”的逻辑变成一行代码:roomStore.switchTo('bedroom'),背后自动触发视角动画、环境贴图切换、甚至门框粒子特效。

2.1 目录结构:三层物理空间映射到代码层级

项目目录严格遵循“空间即模块”原则,避免把所有Three.js代码塞进一个scene.ts:

src/ ├── assets/ │ ├── panoramas/ # 全景图按房间分文件夹,命名即room ID │ │ ├── living-room/ # 含 equirectangular.jpg + lighting.hdr │ │ └── bedroom/ # 同上,HDR光照贴图确保材质反射真实 │ └── models/ # GLB格式家具模型(沙发/床/橱柜),带PBR材质 ├── stores/ │ ├── roomStore.ts # 核心状态:当前房间、可跳转房间列表、门框坐标 │ └── viewerStore.ts # 渲染控制:fov、autoRotate、isDragging ├── composables/ │ ├── useThreeRenderer.ts # 封装renderer初始化、resize监听、render循环 │ ├── usePanoramaScene.ts # 加载全景图、创建球面几何体、应用HDR环境光 │ └── useRoomTransition.ts# 实现房间切换动画:视角位移+淡入淡出+环境贴图渐变 ├── components/ │ ├── VRViewer.vue # Three.js Canvas容器,响应式处理屏幕尺寸变化 │ ├── RoomSelector.vue # 房间导航栏,绑定roomStore.rooms │ └── ControlPanel.vue # 光照/视角参数调节,双向绑定viewerStore └── App.vue # Vue3根组件,仅挂载上述三个核心组件

提示:panoramas/下的HDR文件不是可有可无的装饰——Three.js的PMREMGenerator必须用它生成预过滤的环境贴图(envMap),否则金属材质(如冰箱门、灯具)会显示为灰蒙蒙一片,失去真实感。v1.0默认用three/examples/jsm/loaders/RGBELoader加载.hdr,比用JPEG全景图硬算反射快3倍以上。

2.2 核心依赖版本与关键配置

项目锁定以下版本组合,规避Three.js常见兼容陷阱:

依赖版本关键原因
three^0.152.2R152首次稳定支持WebGL2的EXT_texture_filter_anisotropic扩展,解决远景纹理模糊问题;且OrbitControls修复了触摸设备双指缩放抖动bug
@vueuse/core^10.7.2提供useWindowSize响应式监听,比手动window.addEventListener('resize')更可靠,尤其在移动端横竖屏切换时
@types/three^0.152.0类型定义与R152完全匹配,避免MeshStandardMaterial的metalness属性被误标为number而非float

vite.config.ts中必须开启build.target: 'es2020'——因为Three.js R152使用了BigInt做GPU内存地址计算,低版本ES目标会导致Cannot assign to read only property 'length'报错。同时禁用build.sourcemap(生产环境),否则three/examples/jsm的源码映射会泄露内部路径结构。

2.3 状态管理设计:roomStore如何让“换房间”变成纯函数调用

roomStore.ts是整个系统的空间中枢,其设计直击VR看房的核心痛点:房间切换不能是简单替换图片,而要维持空间连续性。看它的关键字段:

// src/stores/roomStore.ts export const useRoomStore = defineStore('room', () => { // 当前房间ID,驱动全景图加载和UI高亮 const currentRoomId = ref<string>('living-room') // 所有房间元数据,含物理连接关系 const rooms = ref<Room[]>([ { id: 'living-room', name: '客厅', // 门框坐标:[x, y, z]表示该房间内门框中心点的世界坐标 doorwayConnections: [ { targetRoom: 'kitchen', position: [3.2, 0, -4.1] }, { targetRoom: 'bedroom', position: [-2.8, 0, 1.5] } ] }, { id: 'kitchen', name: '厨房', doorwayConnections: [ { targetRoom: 'living-room', position: [-3.2, 0, 4.1] } // 反向连接需显式声明 ] } ]) // 切换房间的纯函数:输入目标ID,输出动画参数 const switchTo = (targetId: string) => { const targetRoom = rooms.value.find(r => r.id === targetId) if (!targetRoom) return // 计算视角移动终点:取目标房间第一个门框位置,z轴微调避免穿模 const targetPosition = [...targetRoom.doorwayConnections[0].position] as [number, number, number] targetPosition[2] += 0.5 // 向门内推进半米,模拟“走进去”动作 // 触发useRoomTransition中的动画逻辑 useRoomTransition().startTransition({ fromRoom: currentRoomId.value, toRoom: targetId, targetPosition, duration: 1200 // 毫秒 }) currentRoomId.value = targetId } return { currentRoomId, rooms, switchTo } })

这段代码的关键在于:switchTo不直接操作Three.js对象,而是通过useRoomTransition()发布一个“我要去厨房”的事件,由专门的组合式函数处理动画细节。这样做的好处是——当你要增加“进入厨房时播放水龙头音效”功能时,只需在useRoomTransition.ts的onComplete钩子里加一行playSound('faucet.mp3'),完全不影响roomStore的纯净性。


3. 核心模块拆解:从加载全景图到实现跨房间跳转的完整链路

3.1 usePanoramaScene:如何把一张JPEG全景图变成可交互的3D球面

全景图加载看似简单,但v1.0做了三处关键增强,避免新手掉坑:

  1. 球面几何体必须用SphereGeometry而非BoxGeometry:后者会导致接缝处像素拉伸,尤其在视角靠近球面时明显;
  2. 纹理映射需关闭repeat并启用sphericalMapping:否则全景图会重复铺满球面,形成诡异的“马赛克地球”;
  3. 必须翻转球面法线:因为相机在球内观察,而Three.js默认法线朝外,不翻转会全黑。
// src/composables/usePanoramaScene.ts import * as THREE from 'three' import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader' import { PMREMGenerator } from 'three/examples/jsm/pmrem/PMREMGenerator' export const usePanoramaScene = () => { let scene: THREE.Scene | null = null let panoramaMesh: THREE.Mesh<THREE.SphereGeometry, THREE.MeshBasicMaterial> | null = null let envMap: THREE.Texture | null = null const loadPanorama = async (panoPath: string, hdrPath: string) => { if (!scene) scene = new THREE.Scene() // 1. 创建球面几何体:半径10,细分度64保证曲面光滑 const geometry = new THREE.SphereGeometry(10, 64, 64) geometry.scale(-1, 1, 1) // 关键!翻转X轴使法线向内 // 2. 加载全景图纹理 const textureLoader = new THREE.TextureLoader() const panoTexture = await textureLoader.loadAsync(panoPath) panoTexture.mapping = THREE.EquirectangularReflectionMapping // 启用球面映射 panoTexture.colorSpace = THREE.SRGBColorSpace // 正确处理sRGB色彩空间 // 3. 创建基础材质(无光照,仅显示全景图) const material = new THREE.MeshBasicMaterial({ map: panoTexture, side: THREE.BackSide // 相机在球内,所以渲染背面 }) // 4. 创建球面网格并加入场景 panoramaMesh = new THREE.Mesh(geometry, material) scene.add(panoramaMesh) // 5. 加载HDR环境贴图并生成预过滤环境贴图(用于PBR材质反射) const rgbeLoader = new RGBELoader() const hdrTexture = await rgbeLoader.loadAsync(hdrPath) const pmremGenerator = new PMREMGenerator(scene.renderer) envMap = pmremGenerator.fromCubemap(hdrTexture).texture pmremGenerator.dispose() // 释放内存 return { scene, envMap } } return { loadPanorama, getScene: () => scene, getEnvMap: () => envMap } }

注意:geometry.scale(-1, 1, 1)这行是玄学级关键点。很多教程只说“翻转法线”,但Three.js中最快的方式就是缩放X轴为-1。如果不加这行,你看到的将是一片漆黑——因为光照计算时法线方向错误,所有像素的漫反射值为0。

3.2 useRoomTransition:房间切换动画的物理引擎

跨房间跳转不是瞬移,v1.0用Three.js的TWEEN库实现贝塞尔曲线运动,确保视角移动符合人眼惯性:

// src/composables/useRoomTransition.ts import * as THREE from 'three' import { TWEEN } from '@tweenjs/tween.js' export const useRoomTransition = () => { let camera: THREE.PerspectiveCamera | null = null let renderer: THREE.WebGLRenderer | null = null let currentTween: TWEEN.Tween<any> | null = null const startTransition = ({ fromRoom, toRoom, targetPosition, duration = 1200 }: { fromRoom: string toRoom: string targetPosition: [number, number, number] duration: number }) => { if (!camera || !renderer) return // 1. 保存当前视角位置和朝向 const startPosition = camera.position.clone() const startQuaternion = camera.quaternion.clone() // 2. 创建目标位置(门框中心点)和朝向(指向门内) const targetQuaternion = new THREE.Quaternion() const direction = new THREE.Vector3(...targetPosition).sub(camera.position).normalize() targetQuaternion.setFromUnitVectors(new THREE.Vector3(0, 0, -1), direction) // 3. 创建TWEEN动画:同时插值位置和四元数 currentTween?.stop() // 停止上一个动画 currentTween = new TWEEN.Tween({ x: startPosition.x, y: startPosition.y, z: startPosition.z, qx: startQuaternion.x, qy: startQuaternion.y, qz: startQuaternion.z, qw: startQuaternion.w }) .to({ x: targetPosition[0], y: targetPosition[1], z: targetPosition[2], qx: targetQuaternion.x, qy: targetQuaternion.y, qz: targetQuaternion.z, qw: targetQuaternion.w }, duration) .easing(TWEEN.Easing.Cubic.InOut) // 缓入缓出,模拟真实移动 .onUpdate((obj) => { camera.position.set(obj.x, obj.y, obj.z) camera.quaternion.set(obj.qx, obj.qy, obj.qz, obj.qw) }) .onComplete(() => { // 动画结束:加载新房间全景图,更新环境贴图 usePanoramaScene().loadPanorama( `/panoramas/${toRoom}/equirectangular.jpg`, `/panoramas/${toRoom}/lighting.hdr` ).then(({ envMap }) => { if (envMap && panoramaMesh) { panoramaMesh.material.envMap = envMap panoramaMesh.material.needsUpdate = true } }) }) .start() } return { startTransition, setCamera: (cam: THREE.PerspectiveCamera) => camera = cam, setRenderer: (rend: THREE.WebGLRenderer) => renderer = rend } }

这段代码的精妙之处在于:它把“位置移动”和“朝向旋转”统一用四元数插值。如果分开用camera.position.lerp()和camera.quaternion.slerp(),会出现朝向突变(因为四元数球面线性插值Slerp比欧拉角插值更平滑)。v1.0实测,用此方案切换房间时,用户不会感到眩晕——这是VR体验的生死线。

3.3 VRViewer.vue:Canvas容器的响应式陷阱与修复

VRViewer.vue表面只是个<canvas>标签,但藏着三个移动端致命坑:

  1. iOS Safari的touch-action: none失效:导致双指缩放时页面整体滚动;
  2. Canvas尺寸被CSSwidth: 100%拉伸变形:实际渲染分辨率与CSS尺寸不一致;
  3. requestAnimationFrame在后台标签页被节流:切到其他浏览器标签后返回,动画卡顿。
<!-- src/components/VRViewer.vue --> <template> <div ref="containerRef" class="vr-container" @touchstart.prevent @touchmove.prevent > <canvas ref="canvasRef" class="vr-canvas" :style="{ width: '100%', height: '100%' }" /> </div> </template> <script setup lang="ts"> import { onMounted, onUnmounted, ref, watch } from 'vue' import * as THREE from 'three' import { useWindowSize } from '@vueuse/core' import { useThreeRenderer } from '@/composables/useThreeRenderer' import { usePanoramaScene } from '@/composables/usePanoramaScene' const containerRef = ref<HTMLDivElement | null>(null) const canvasRef = ref<HTMLCanvasElement | null>(null) // 1. 使用useWindowSize而非window.resize事件,避免iOS Safari resize延迟 const { width, height } = useWindowSize() // 2. 初始化Three.js渲染器(自动绑定canvasRef) const { renderer, scene, camera } = useThreeRenderer(canvasRef) // 3. 加载初始全景图 onMounted(() => { if (!containerRef.value || !canvasRef.value) return // 关键修复:强制设置canvas实际像素尺寸,避免CSS拉伸 const pixelRatio = window.devicePixelRatio || 1 canvasRef.value.width = Math.floor(width.value * pixelRatio) canvasRef.value.height = Math.floor(height.value * pixelRatio) renderer.setSize(canvasRef.value.width, canvasRef.value.height) renderer.setPixelRatio(pixelRatio) // 加载客厅全景图 usePanoramaScene().loadPanorama( '/panoramas/living-room/equirectangular.jpg', '/panoramas/living-room/lighting.hdr' ) }) // 4. 响应式重置渲染器尺寸 watch([width, height], () => { if (!canvasRef.value) return const pixelRatio = window.devicePixelRatio || 1 canvasRef.value.width = Math.floor(width.value * pixelRatio) canvasRef.value.height = Math.floor(height.value * pixelRatio) renderer.setSize(canvasRef.value.width, canvasRef.value.height) // 不需要调用renderer.setPixelRatio再次设置,已在mounted中完成 }) // 5. 生命周期清理 onUnmounted(() => { renderer.dispose() // 清理TWEEN全局缓存,防止内存泄漏 TWEEN.removeAll() }) </script> <style scoped> .vr-container { width: 100%; height: 100vh; overflow: hidden; /* 防止iOS Safari滚动条 */ touch-action: none; /* 关键:禁用浏览器默认触摸行为 */ } .vr-canvas { display: block; width: 100%; height: 100%; } </style>

提示:touch-action: none必须写在.vr-container上,而不是<canvas>上——因为iOS Safari只对div等块级元素生效。曾经有团队把这行写在canvas上,结果在iPhone上双指缩放时页面疯狂滚动,调试了两天才发现是CSS作用域错了。


4. 运行与调试:从零启动到真机验证的完整流程

4.1 本地开发环境搭建:5分钟跑通第一步

别被“VR”吓住,v1.0的本地启动比多数Vue项目还简单:

# 1. 克隆项目(假设已下载源码包) git clone https://github.com/xxx/vue3-threejs-vr-house.git cd vue3-threejs-vr-house # 2. 安装依赖(v1.0已锁死版本,无需--legacy-peer-deps) npm install # 3. 启动开发服务器(Vite自动处理Three.js模块) npm run dev

此时打开http://localhost:5173,你应该看到:

  • 一个可拖拽旋转的客厅全景图;
  • 底部有“客厅”“厨房”“卧室”三个按钮;
  • 点击“厨房”按钮,视角平滑移动到厨房门框位置,并加载厨房全景图。

注意:如果看到黑屏,请立即检查浏览器控制台——90%概率是panoramas/路径错误。v1.0要求全景图必须放在public/panoramas/下(Vite的静态资源目录),而非src/assets/。因为TextureLoader加载的是绝对URL,src/assets/里的文件会被Webpack处理成hash路径,导致加载失败。

4.2 真机调试避坑指南:iOS与Android的差异战场

真机测试不是“能跑就行”,而是要解决平台特异性问题:

现象1:iPhone上拖拽卡顿,帧率跌到20fps

原因:iOS Safari的requestAnimationFrame在非活跃标签页被深度节流,且OrbitControls的dampingFactor默认值0.05在iOS上响应过慢。
解决:在useThreeRenderer.ts中调整阻尼系数,并添加页面可见性监听:

// src/composables/useThreeRenderer.ts import { documentVisibility } from '@vueuse/core' // 在renderer初始化后添加 const visibility = documentVisibility() watch(visibility, (v) => { if (v === 'visible') { // 页面回到前台,重置controls阻尼 controls.dampingFactor = 0.12 // iOS需更高阻尼 } else { controls.dampingFactor = 0.02 // 后台降低阻尼省电 } })
现象2:Android Chrome点击门框无反应

原因:部分Android机型(尤其三星)的touchend事件触发延迟,导致click事件未被Three.js的Raycaster捕获。
解决:改用pointerdown事件替代click,并在RoomSelector.vue中:

<!-- src/components/RoomSelector.vue --> <template> <div v-for="room in roomStore.rooms" :key="room.id" @pointerdown="handleRoomClick(room.id)" <!-- 关键:pointerdown比click更及时 --> class="room-btn" > {{ room.name }} </div> </template>
现象3:iPad上双指缩放失灵

原因:iPadOS 16+默认启用“辅助触控”,会劫持双指手势。
解决:在VRViewer.vue的mounted钩子中注入CSS:

onMounted(() => { // 强制禁用iPad辅助触控对canvas的影响 const style = document.createElement('style') style.textContent = ` @media (min-width: 768px) { .vr-container { -webkit-user-select: none; -webkit-touch-callout: none; } } ` document.head.appendChild(style) })

4.3 性能监控:用Three.js内置工具定位卡顿源

v1.0内置了性能面板开关(按Ctrl+Shift+P或Cmd+Shift+P),但你需要知道怎么看:

  1. FPS低于45?优先检查panoramas/下的HDR文件大小——超过8MB的HDR会阻塞主线程。解决方案:用hdri-to-cubemap工具将HDR转为6张JPG立方体贴图,体积减少60%;
  2. Memory持续上涨?检查usePanoramaScene.ts中PMREMGenerator是否被正确dispose()——v1.0在每次加载新全景图前都会调用pmremGenerator.dispose(),漏掉这行会导致GPU内存泄漏;
  3. Draw Calls超200?查看console.log(renderer.info)——如果render.calls过高,说明场景中存在大量独立Mesh。v1.0将所有家具模型合并为一个InstancedMesh,把Draw Calls从300+压到12。

血泪经验:某次上线前夜,我们发现iPad Pro上内存每分钟涨50MB。排查三天,最终发现是OrbitControls的addEventListener('wheel')没被removeEventListener清除——因为Vue组件卸载时,onUnmounted钩子没覆盖到controls实例。从此我养成了一个习惯:所有Three.js事件监听器,必须用onBeforeUnmount(而非onUnmounted)清理,确保在DOM移除前就解绑。


5. 二次开发实战:给系统加一个“白天/夜晚模式”切换功能

现在你已经跑通了基础VR看房,下一步往往是业务方提需求:“能不能加个开关,让客户看房时一键切换白天和夜晚效果?”——这看似是改个光照参数,实则考验你对Three.js光照管线的理解深度。v1.0预留了viewerStore的ambientLightIntensity和directionalLightIntensity两个可响应式字段,但直接改它们只会让画面变亮变暗,缺乏真实感。真正的白天/夜晚模式,应该改变光源色温、环境光强度、甚至材质粗糙度。下面带你一步步实现。

5.1 设计思路:用两套光照参数+材质动态更新

Three.js中没有“白天模式”API,但我们可以模拟:

  • 白天:主光源色温6500K(偏蓝白),环境光强度0.3,材质roughness保持0.8(模拟自然漫反射);
  • 夜晚:主光源色温2700K(暖黄),环境光强度0.05,材质roughness降到0.3(灯光下金属更反光)。

关键是要让所有PBR材质(沙发、灯具、地板)同步响应,而不是只改光源。

5.2 修改viewerStore:新增mode状态与参数映射

// src/stores/viewerStore.ts export const useViewerStore = defineStore('viewer', () => { const mode = ref<'day' | 'night'>('day') // 新增模式状态 // 白天/夜晚参数映射表 const lightParams = computed(() => { if (mode.value === 'day') { return { ambientIntensity: 0.3, directionalIntensity: 1.2, directionalColor: 0xffffff, // 白色 envMapIntensity: 1.0 } } else { return { ambientIntensity: 0.05, directionalIntensity: 0.8, directionalColor: 0xffcc99, // 暖黄 envMapIntensity: 0.3 // 夜晚环境光减弱 } } }) // 切换模式的函数 const toggleMode = () => { mode.value = mode.value === 'day' ? 'night' : 'day' } return { mode, lightParams, toggleMode } })

5.3 更新usePanoramaScene:让材质响应模式变化

usePanoramaScene.ts需要监听lightParams变化,并更新所有PBR材质:

// src/composables/usePanoramaScene.ts import { useViewerStore } from '@/stores/viewerStore' export const usePanoramaScene = () => { // ...原有代码... // 新增:应用光照参数到材质 const applyLightParams = (params: ReturnType<typeof useViewerStore>['lightParams']['value']) => { if (!panoramaMesh) return // 更新全景图材质的环境光强度(影响反射亮度) const basicMaterial = panoramaMesh.material as THREE.MeshBasicMaterial basicMaterial.envMapIntensity = params.envMapIntensity // 如果场景中有PBR材质(如加载的家具模型),遍历更新 scene?.traverse((object) => { if (object.isMesh) { const mesh = object as THREE.Mesh if (mesh.material instanceof THREE.MeshStandardMaterial) { mesh.material.roughness = mode.value === 'day' ? 0.8 : 0.3 mesh.material.metalness = mode.value === 'day' ? 0.2 : 0.7 } } }) } // 监听viewerStore变化 const viewerStore = useViewerStore() watch(() => viewerStore.lightParams, applyLightParams, { immediate: true }) return { // ...原有返回值... applyLightParams } }

5.4 添加UI开关:ControlPanel.vue中集成

<!-- src/components/ControlPanel.vue --> <template> <div class="control-panel"> <!-- 其他控件... --> <div class="mode-switch"> <span>白天</span> <label class="switch"> <input type="checkbox" :checked="viewerStore.mode === 'night'" @change="viewerStore.toggleMode" /> <span class="slider"></span> </label> <span>夜晚</span> </div> </div> </template> <script setup lang="ts"> import { useViewerStore } from '@/stores/viewerStore' const viewerStore = useViewerStore() </script> <style scoped> .mode-switch { display: flex; align-items: center; gap: 12px; } .switch { position: relative; display: inline-block; width: 52px; height: 26px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; } .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; } input:checked + .slider { background-color: #2196F3; } input:checked + .slider:before { transform: translateX(26px); } </style>

5.5 验证与调优:为什么你的“夜晚模式”看起来假?

做完以上步骤,你可能发现夜晚模式只是变暗了,但缺乏真实感。这是因为漏掉了两个隐藏环节:

  1. HDR环境贴图未切换:v1.0的panoramas/文件夹下,每个房间应有两套HDR:lighting-day.hdr和lighting-night.hdr。修改usePanoramaScene.ts的loadPanorama函数,根据viewerStore.mode选择加载哪个HDR;
  2. 后处理未启用Bloom:夜晚的灯光需要泛光效果。v1.0已集成UnrealBloomPass,只需在useThreeRenderer.ts中:
// src/composables/useThreeRenderer.ts import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass' // 在renderer初始化后 const bloomPass = new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, // strength 0.4, // radius 0.85 // threshold ) bloomPass.renderToScreen = true // 根据模式开关bloom watch(() => viewerStore.mode, (mode) => { bloomPass.enabled = mode === 'night' })

从那以后我每次加新光照效果,都强制走一遍“白天/夜晚对比测试”:先在Chrome开DevTools的Rendering面板,勾选FPS Meter和Paint Flashing,然后反复切换模式,观察FPS是否稳定、是否有意外重绘。如果某个材质在切换时闪烁,一定是material.needsUpdate = true没被正确调用——Three.js的材质更新不是响应式的,必须手动标记。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进