简介:一个基于React和Three.js的3D全景漫游项目,面向计算机专业学生、毕业设计者及有项目实战需求的开发者。该项目由导师指导并获九十八分高分评审,源码经过本地编译与严格调试,确保可运行,难度适中,适合用于课程作业或毕业设计参考。压缩包共17个文件,大小16.87MB,以tsx、ts、js、json和html为主要类型,其中tsx与ts负责组件和逻辑实现,json与html用于场景配置和页面入口,less用于样式,md与txt提供说明,整体构成完整的React加TypeScript前端工程。已有1262人学习下载。借助该资源,读者可快速了解React与Three.js的集成方式,学习全景漫游场景的搭建、交互控制与页面实现,同时可作为高质量项目模板,参考其目录划分与编码规范。项目还附带了构建配置和依赖管理文件,便于直接编译运行,适合在三维可视化与元宇宙相关方向上深入练习。
1. 元宇宙初探:React + Three.js 做 3D 全景漫游,为什么是门槛最低的 Web3D 入口
打开一个 3D 看房链接,拖动鼠标从客厅转到阳台,点一下墙角的小圆点直接"走"进卧室——这种体验现在被称作元宇宙场景,技术底座其实就是 React + Three.js。这份"元宇宙初探:React + Three.js 制作 3D 全景漫游"资源,带的是一个可直接运行的前端项目:用等距柱状全景图贴球面,配合 OrbitControls 做视角旋转,再加上多场景切换与热点交互。整体不需要建模、不需要后端,一张全景图加一套前端骨架就能出效果。
适合的读者很明确:还在观望 Three.js 的前端工程师、要交 Web3D 课设或毕设的学生、想在 3D 看房或虚拟展厅赛道上快速验证玩法的人。想直接学到"图贴上去、转起来、点得动"这三步,这篇就是照着复现的完整记录。
2. 核心原理:全景图如何变成可转动的 3D 空间
2.1 等距柱状投影:一张 2:1 的图就是整个空间
全景漫游的原料很简单:一张分辨率够大的 JPG。常见的全景图是"等距柱状投影"(Equirectangular),宽高比固定为 2:1,比如 4096 × 2048。横向 360°、纵向 180°,图片的左右边缘在空间中其实是同一个点。你在网上下载的全景素材、全景相机拍出来的原图,基本都是这个格式。
Three.js 里复现这个空间的做法是:创建一个巨大的球体,把全景图作为纹理贴到球内壁,再把相机放到球心。其中SphereGeometry的半径取多大其实无所谓——相机在球心,距离球壁多远都不会影响贴图观感,常见取 100 或 500,主要是避免和其他物体产生 z-fighting。
这里最关键的是材质必须设置side: THREE.BackSide。Three.js 默认渲染球体外表面,球心处的相机看出去是一片黑,BackSide让材质显示在球内壁,这才是全景漫游成立的前提。材质用MeshBasicMaterial就够,全景图本身已经烘焙了光照信息,不需要再挂灯光,这也是全景方案比传统 3D 场景省性能的根本原因。
2.2 相机不移动,动的是视线方向
相机用PerspectiveCamera,视场角 FOV 取 70~80 比较贴近人眼。太小会有望远镜感,太大画面边缘畸变明显。近裁面near给 0.1,远裁面far给 1000 就够,球壁就是场景里最远的物体。
视角怎么转动?这里有个容易翻车的地方:初学者习惯让相机绕场景旋转,但全景漫游恰好相反——相机不能动,动的是"视线方向"。常用的做法是引入 OrbitControls,把它配置成"只旋转"模式:
const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0, 0); controls.enableZoom = false; controls.enablePan = false; controls.minDistance = 0.1; controls.maxDistance = 0.1; controls.enableDamping = true; controls.rotateSpeed = 0.6;这段配置里最容易被忽略的是minDistance / maxDistance。OrbitControls 的逻辑是让相机绕 target 旋转,如果相机位置恰好和 target 重合,旋转会直接失效。所以相机要摆在(0, 0, 0.1)这种离球心"一丢丢"的地方,再用上下限把运动半径锁死,视觉上就等价于原地转头。enableDamping开启后会有惯性滑动手感,但注意每次requestAnimationFrame里必须调用controls.update(),否则阻尼不生效——这是 Three.js 里最常见的"玄学"问题之一。
另一种更可控的方案是直接监听 pointer 事件改写相机欧拉角,比如camera.rotation.y -= movementX * 0.002。这个方法对陀螺仪、触摸滑动的适配更灵活,资源里的移动端方案就是这么做的。但桌面上我一般还是优先用 OrbitControls,惯性阻尼免费,代码量更少。
2.3 最小 Demo:十分钟跑起一个能转的场景
完整的最小实现如下,这也是整个资源的地基:
import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; const container = document.getElementById('pano'); const scene = new THREE.Scene(); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(container.clientWidth, container.clientHeight); container.appendChild(renderer.domElement); const camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000); camera.position.set(0, 0, 0.1); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0, 0); controls.enableZoom = false; controls.enablePan = false; controls.minDistance = 0.1; controls.maxDistance = 0.1; controls.enableDamping = true; const sphere = new THREE.Mesh( new THREE.SphereGeometry(100, 64, 32), new THREE.MeshBasicMaterial({ side: THREE.BackSide }) ); scene.add(sphere); new THREE.TextureLoader().load('/panos/lobby.jpg', (texture) => { texture.colorSpace = THREE.SRGBColorSpace; sphere.material.map = texture; sphere.material.needsUpdate = true; }); function tick() { controls.update(); renderer.render(scene, camera); requestAnimationFrame(tick); } tick();代码逻辑说明:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))把高分屏的渲染像素比限制在 2,避免手机端渲染压力过大。SphereGeometry(100, 64, 32)的 64/32 是球体分段数。全景图分辨率高,分段太少球面会出现可见折痕,64×32 是清晰度和性能的平衡点。texture.colorSpace = THREE.SRGBColorSpace很关键。Three.js r152 之后默认开启颜色管理,但纹理必须手动标记 sRGB,否则全景图整体发灰。needsUpdate = true是因为纹理加载是异步的,赋值后通知材质重新编译。
到这里,一个能转的全景场景就齐了。分辨率对清晰度的影响也要提一句:同为全景图,1920 × 960 在 FOV 75° 下勉强能看,拖到角落放大就会糊;这套资源里的示例图是 4096 × 2048,球面分段和纹理过滤都按这个规格调过。如果你的素材只有 2000 像素宽,建议把 FOV 缩到 60~65,画面会明显更紧实。
3. 集成进 React:把 Three.js 场景变成可维护的组件
3.1 原生 Three.js 还是 React Three Fiber
全景漫游这种交互相对独立的场景,业界有两条路:一条是直接用useEffect管理 Three.js 生命周期,另一条是用 React Three Fiber(R3F),把 scene、mesh 声明式地写成 JSX。R3F 的组件化写法在复杂项目里很舒服,但对"场景固定、只有纹理和热点变化"的轻交互来说,反而多了一层虚拟场景,问题不好追。
这份资源选的是原生 Three.js + React 函数组件:状态归 React,3D 实例归useRef,边界清晰。对比一下:
| 维度 | useEffect + 原生 Three.js | React Three Fiber |
|---|---|---|
| 生命周期管理 | 手动创建/销毁,可控性强 | 组件挂载自动管理 |
| 调试难度 | 直接看 canvas 上发生了什么 | 多一层虚拟场景,报错定位绕路 |
| React 状态同步 | 用 ref 桥接 | 声明式绑定 |
| 适用场景 | 单一全景、少量热点 | 复杂场景图、多模型、动画状态机 |
3.2 组件骨架:useRef 挂载容器,useEffect 管生命周期
组件化的核心是:DOM 容器用ref交给 Three.js 挂 canvas,Three.js 的所有实例放进一个threeRef,React 不直接操作它们。这样切场景、点热点时,事件回调里永远能拿到最新的 3D 对象,不会闭包捕获过期状态。
function PanoramaViewer({ imageUrl }) { const containerRef = useRef(null); const threeRef = useRef(null); useEffect(() => { const container = containerRef.current; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(container.clientWidth, container.clientHeight); container.appendChild(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000); camera.position.set(0, 0, 0.1); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0, 0); controls.enableZoom = false; controls.enablePan = false; controls.enableDamping = true; const sphere = new THREE.Mesh( new THREE.SphereGeometry(100, 64, 32), new THREE.MeshBasicMaterial({ side: THREE.BackSide }) ); scene.add(sphere); const three = { renderer, camera, controls, sphere }; threeRef.current = three; const tick = () => { controls.update(); renderer.render(scene, camera); three.raf = requestAnimationFrame(tick); }; tick(); return () => { cancelAnimationFrame(three.raf); controls.dispose(); sphere.geometry.dispose(); sphere.material.dispose(); renderer.dispose(); if (renderer.domElement.parentNode) { renderer.domElement.parentNode.removeChild(renderer.domElement); } threeRef.current = null; }; }, [imageUrl]); return <div ref={containerRef} className="pano-container" />; }参数说明:
threeRef是整个场景的"句柄",之后热点点击、场景切换都从它取对象,避免事件回调里闭包引用旧值。cancelAnimationFrame放在 cleanup 第一位,否则组件卸载后渲染循环还在跑,控制台会报 WebGL context lost。- 依赖数组
[imageUrl]让组件在切场景时自动销毁重建。这个写法简单可靠,但相机视角会跟着重置;第 4 章会讲不销毁、只换纹理的体验优化方案。
3.3 React 的 state 和 Three.js 的循环别互相绑架
第一次做 React + Three.js 最容易踩的坑,是用 setState 驱动每一帧。比如在onPointerMove里setRotationY(x),再用 useEffect 监听 state 去改camera.rotation。这个链路每帧都触发 React 调度,30fps 都保不住。正确的边界是:React 只管"什么时候切换场景、热点配置是什么",Three.js 内部用自己requestAnimationFrame循环处理连续变化。
交互事件里需要读最新值的时候,从threeRef.current取,不要在回调里直接引用组件闭包里的旧变量。这个习惯养成了,后面加自动旋转、加陀螺仪、加过渡动画都不会打架。
热点其实还有一条 DOM 实现的捷径:把 3D 坐标用vector.project(camera)投影成屏幕坐标,然后渲染普通<button>。这样 React 侧写样式、写动画都方便,但这套方案的前提取决于每帧刷新投影位置。资源里两种都实现了:DOM overlay 用于展示箭头按钮,Raycaster 用于热区点击判定,下面一章重点讲 Raycaster,因为它是纯 3D 方案。
4. 完整实现:多场景切换与热点交互
4.1 场景配置:用 JSON 而不是硬编码
把场景当成数据,是全套代码里最值得抄的设计。每个场景一个条目,字段固定,热点挂在场景下面,前端和内容彻底解耦:
{ "version": 1, "initialScene": "lobby", "scenes": { "lobby": { "image": "/panos/lobby.jpg", "name": "大堂", "hotspots": [ { "id": "hs-1", "target": "garden", "label": "去花园", "position": [40, 10, -80] }, { "id": "hs-2", "target": "stairs", "label": "上二楼", "position": [-30, 20, -60] } ] } } }position是热点在球面上的三维方向向量,写的时候不用手动归一化,组件内部统一normalize()处理。但有三个细节要注意:z 轴负方向是 Three.js 相机默认的朝向前方,所以热点尽量放在z < 0的区域,保证用户一进场景就能在视野里看到提示,否则就会出现"热点全在背后,转了半天找不到"的尴尬;坐标写大一点没关系,射线检测只取方向;id全局唯一,切换场景全靠它索引。
4.2 Raycaster 热区点击:从屏幕坐标反推三维方向
在 3D 场景里实现"点击小球跳转场景",标准做法是 Raycaster:先把鼠标屏幕坐标换算成 NDC 坐标,Raycaster 从相机位置发射一条射线,与场景里的热点 Mesh 求交,命中后从userData取出目标场景 id。
const raycaster = new THREE.Raycaster(); const ndc = new THREE.Vector2(); const hotspotMeshes = []; function createHotspotMesh(spot) { const mesh = new THREE.Mesh( new THREE.SphereGeometry(0.8, 16, 16), new THREE.MeshBasicMaterial({ color: 0xffcc00 }) ); const dir = new THREE.Vector3(...spot.position).normalize(); mesh.position.copy(dir.multiplyScalar(90)); // 球半径 100,热点贴在球内壁附近 mesh.userData.targetSceneId = spot.target; hotspotMeshes.push(mesh); return mesh; } function onPointerDown(event) { const rect = renderer.domElement.getBoundingClientRect(); ndc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; ndc.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(ndc, camera); const hits = raycaster.intersectObjects(hotspotMeshes, false); if (hits.length > 0) { switchToScene(hits[0].object.userData.targetSceneId); } }逻辑说明:
- NDC 坐标 x 范围 [-1, 1] 从左到右,y 范围 [-1, 1] 从下到上,所以 y 方向要取反。
- 热点球半径 0.8 是为了让命中区域精确,但手指在手机上点不准。实际项目我会在外面再包一个透明的大球做"热区 padding":
Material({ transparent: true, opacity: 0 }),透明度为 0 的物体 Raycaster 默认照样参与检测。 intersectObjects第二个参数设 false,热点 Mesh 都是独立对象,不递归子物体,省性能。- 事件绑定在
renderer.domElement上,不要绑 window,否则 UI 按钮点击也会触发射线检测。
4.3 切换场景:换纹理而不是重建场景
切换场景有两条路线。路线 A 是 React 层换imageUrl,触发 useEffect 重建整个 Three.js 场景(第 3 章的写法)。路线 B 是在不销毁渲染器的前提下,把新纹理换到同一个球体上。路线 B 体验明显更好——没有 WebGL 上下文重建的白屏闪烁,相机旋转状态、controls 状态全部保留,切换观感是"丝滑转身"而不是"页面刷新"。
路线 B 的核心是异步加载加淡入淡出:
async function switchToScene(targetId) { const next = scenesConfig.scenes[targetId]; if (!next) return; fadeOut(); // 用一个 CSS 覆盖层把画面渐隐 const texture = await loadTexture(next.image); const oldTexture = sphere.material.map; sphere.material.map = texture; sphere.material.needsUpdate = true; if (oldTexture) oldTexture.dispose(); // 关键:旧纹理必须释放 fadeIn(); } function loadTexture(url) { return new Promise((resolve, reject) => { loader.load(url, resolve, undefined, reject); }); }参数说明:
- 旧的
map不 dispose,GPU 显存会堆满历史纹理,切五六次之后移动端必卡,这是全景漫游内存泄漏最主要的来源。 needsUpdate = true通知材质重新编译。换 map 纹理时在 r150+ 其实可不写,保留它没坏处,能规避版本差异。- fade 直接用 CSS opacity 过渡,别在 Three.js 里做材质透明度动画,DIV overlay 省心得多。
4.4 加载进度与错误兜底
全景图动辄 4K,网速慢的时候不能干等黑屏。TextureLoader.load的第二个参数就是进度回调,接一个进度条,至少让用户知道"在加载,没死":
function loadTexture(url, onProgress) { return new Promise((resolve, reject) => { loader.load(url, resolve, onProgress, reject); }); } // 使用 loadTexture(next.image, (xhr) => { const percent = (xhr.loaded / xhr.total) * 100; setLoadingText(`${Math.round(percent)}%`); });错误处理也要有:onError里把纹理切回默认纯色背景,并给一个"重新加载"按钮。全景图加载失败时最常见的现象是球体渲染成纯黑,因为map为 null,MeshBasicMaterial会输出黑色。显式给color: 0x111122当兜底,比黑屏友好得多。
注意:进度回调里的
xhr.total在部分浏览器里可能是 0,要加一层xhr.total > 0 ? ... : '...'的兼容,否则进度条直接显示 NaN%。
5. 避坑排查:全景漫游最容易翻车的五个现场
5.1 手机陀螺仪不动:iOS 权限和方向校准
现象:同一套代码,安卓手机晃动能转视角,iPhone 纹丝不动。
原因:iOS 13 以后DeviceOrientation事件必须由用户手势触发授权,而且页面必须是 HTTPS;另一个常见原因是把e.alpha直接当水平角用,没有减去初始朝向做校准,导致角度数值对不上,画面像被"锁死"。
解决:在"进入漫游"按钮的 click 里先请求权限,拿到授权后再监听deviceorientation。拿到alpha / beta / gamma后做一次初值校准,用camera.rotation.y = (alpha - initAlpha) * THREE.MathUtils.DEG2RAD,否则用户举起手机那一刻画面会突然跳一下。血泪经验:不校准的话,iOS 和安卓的初始 alpha 基准还不一样,同一个方向两个平台表现完全不同。
5.2 全景图颜色发灰:颜色空间必须显式声明
现象:同一张图,在图片查看器里色彩鲜艳,进 Three.js 之后灰了一层。
原因:r152 之后 Three.js 默认开启颜色管理,渲染输出走 sRGB,但纹理如果没标记SRGBColorSpace,材质采样时按线性空间处理,暗部被抬升,整体发灰。
解决:在TextureLoader.load回调里立刻执行texture.colorSpace = THREE.SRGBColorSpace,不要等赋值给 material 之后才想起来。如果项目锁在 r150 以下,对应写法是texture.encoding = THREE.sRGBEncoding,升级 Three.js 时这是最容易悄悄翻车的破坏性变更。
5.3 React StrictMode 双挂载:场景重影和性能翻倍
现象:开发环境打开页面,控制台出现两个 canvas,帧率掉一半,切几次场景后出现 "Context Lost"。
原因:React 18 StrictMode 在开发模式会故意 mount → unmount → mount,useEffect执行两遍。cleanup 不彻底的话,两个 WebGLRenderer 同时存活,显存双倍占用。
解决:cleanup 里按逆序处理:cancelAnimationFrame→controls.dispose→geometry.dispose→material.dispose→renderer.dispose→ 移除 DOM 节点。再加一道保险:拿到renderer.getContext()后调用WEBGL_lose_context扩展强制回收上下文,这是我在生产环境常用的兜底手段。
5.4 本地双击打开黑屏:CORS 与 file:// 协议
现象:把构建产物目录直接双击 index.html 打开,全景图加载失败,控制台报 CORS 错误。
原因:TextureLoader底层是 HTMLImageElement,file:// 协议下浏览器对本地图片的读取受同源策略限制。
解决:不要用 file:// 打开。本地开发走npm run dev,构建产物预览用npx serve dist起一个静态服务。资源里/panos的路径是相对 public 目录映射的,换成别的静态服务器时只要保持目录结构一致就不会出问题。
5.5 切场景后越来越卡:纹理释放与显存回收
现象:连续切换七八个场景,手机开始发热,帧率掉到 20 左右。
原因:每次switchToScene都加载新纹理,旧纹理只是被替换掉,没有dispose()。球体只有一个,但 GPU 显存里所有历史纹理都还占着。一张 4096 × 2048 的 RGBA 纹理约 32MB,五个场景就是 160MB,显存直接告急。
解决:按第 4.3 节写法,换纹理前oldTexture.dispose()。验证方法:每切一次场景,在控制台看renderer.info.memory.textures的值,正常情况下它只增长到场景总数,之后不再上涨。注意 info 里的数值需要触发过一次 render 才会刷新,切完场景后等一帧再看。
6. 进阶打磨:把全景漫游调出"顺滑感"
基础功能跑通之后,真正拉开体验差距的是这几个参数。首先是 OrbitControls 的阻尼和视角范围:dampingFactor默认 0.05,实话说偏肉,我一般调到 0.08;上下视角用minPolarAngle/maxPolarAngle限一下,防止用户低头看到球体底部的极点拉伸,那画面一出来就露馅。
controls.dampingFactor = 0.08; controls.minPolarAngle = THREE.MathUtils.degToRad(10); controls.maxPolarAngle = THREE.MathUtils.degToRad(170);再就是调试效率问题。全景参数靠"改代码刷新看"太慢,我习惯挂一个 lil-gui 调试面板,把 rotateSpeed、FOV、autoRotate 全暴露出来实时拖:
import GUI from 'lil-gui'; const gui = new GUI(); gui.add(controls, 'rotateSpeed', 0.1, 2, 0.05); gui.add(controls, 'enableDamping'); gui.add(controls, 'autoRotate'); gui.add(camera, 'fov', 40, 110, 1).onChange(() => camera.updateProjectionMatrix());FOV 修改后必须调updateProjectionMatrix(),这是新手最容易漏的一步。autoRotate适合展厅大屏场景,无人操作时自己缓缓转,配上阻尼手感非常高级。移动端性能这块,setPixelRatio上限 2 是底线,热点球体几何体可以共用一个SphereGeometry实例,省显存也省 draw call。
从那以后,我每做完一套全景漫游,都要强制走一遍三连检:旧纹理有没有 dispose、controls.update()是否在渲染循环里、renderer.info.memory.textures涨没涨。这套 React + Three.js 全景漫游的资源也一样——跑通不难,跑顺才是真功夫。希望帮到你。
本文还有配套的精品资源,点击获取