简介:基于 Vue3 与 three.js 打造的智慧校园 3D 可视化前端项目源码,面向具备前端基础、希望系统学习 Web 三维开发的工程师和学习者,可帮助快速搭建可交互的校园场景,并理解从模型加载、场景构建到用户交互的完整实现链路。压缩包共 142 个文件,大小约 193.42MB,核心代码包含 Vue 单文件组件、JavaScript 逻辑文件,场景资源涵盖 GLB/GLTF 三维模型、PNG/JPG 贴图素材、MP4 视频以及 glsl/frag 着色器文件,并带有工程配置与运行依赖;目录中模型、样式、脚本分层清晰,便于按需替换和二次开发。项目支持场景旋转缩放、多预设视角切换、模型自动旋转,并能在三维场景内播放视频,交互逻辑基于 Vue3 组合式 API 与 three.js 的场景、相机、渲染器协同实现,可帮助开发者掌握几何体、材质、光源、动画循环等核心概念,也适合作为智慧校园、数字孪生类项目的起步模板。目前已有 1497 人学习浏览,相比零散教程,这套源码能直接安装依赖并运行调试,从环境搭建到功能实现都有明确入口,可降低技术踩坑成本;同时模型、贴图与交互脚本分类齐全,适合用于课程设计或毕业设计参考。 这段时间一直在忙一个智慧校园的三维可视化项目,技术栈锁定在 Vue3 + Three.js。整体看下来,Vue3 的响应式机制和组合式 API 跟 Three.js 这种偏命令式的渲染引擎搭配起来,比 Vue2 时期顺手得多。但真正把一个 3D 场景从“能转”做到“能用、能管、能联动业务”,中间踩的坑是真不少。这篇文章就把这套智慧校园 3D 场景从技术选型、场景搭建到业务联动、性能优化完整梳理一遍,分享给正在做或者准备做同类 Web3D 可视化项目的人参考。
1. 为什么最终选了 Vue3 + Three.js 这套组合
1.1 智慧校园项目到底在解决什么问题
做技术选型之前,得先想清楚甲方要什么。智慧校园 3D 场景不是简单地做个学校模型放网页上转两圈,它要承接的是校园可视化管理的诉求:把教学楼、宿舍、食堂、操场这些物理实体,和课表、设备状态、人员位置这些业务数据在同一个三维空间里叠起来。
具体到我们这个项目,核心需求有四条:第一,把整个校区的建筑和地形按真实位置还原出来;第二,用户可以在场景中自由漫游、切换视角;第三,点击任意建筑或教室,能弹出对应的信息面板;第四,教室的实时使用状态、设备的在线状态要能跟着后端数据变化。这几条需求直接决定了技术选型的方向——需要一个成熟的 Web 渲染引擎,还需要一个能快速落地业务界面的前端框架。
1.2 Vue3 比 Vue2 更适合做这件事的三个理由
很多教程还在用 Vue2 配 Three.js,但做了这个项目之后我的感受是,Vue3 有本质性的优势,不只是语法层面的变化。
第一个理由是组合式 API。Three.js 的代码量大、状态分散,场景、相机、渲染器、灯光、模型各是各的。Vue2 时代习惯用 options API 把 setup 里乱七八糟的逻辑硬塞进 data、methods、mounted 里面,一个 3D 场景动辄上千行,mounted 里堆一堆初始化代码,维护起来极其痛苦。Vue3 的 setup 函数天然适合把 Three.js 的初始化逻辑拆成一个个可复用的组合函数,比如 useScene、useCamera、useRenderer、useControls,每个模块各管一摊,代码结构清晰很多。
第二个理由是生命周期更合理。Vue2 里组件销毁后要手动清 Three.js 的资源,稍不注意就会内存泄漏。Vue3 的 onBeforeUnmount 钩子和组合式函数结合起来,可以很自然地封装一个 useThree 函数,在 onBeforeUnmount 里统一做 renderer.dispose()、scene.clear()、控制器的 dispose(),避免一个个记。
第三个理由是响应式系统的差异。Vue2 的响应式是基于 Object.defineProperty 实现的,对对象属性的新增和删除侦测不到,存在很多边界情况;Vue3 的 Proxy 响应式虽然也要注意别把 Three.js 的实例对象直接丢进 reactive 里,但整体上对复杂对象的状态管理要稳得多。我们后面会详细讲这个坑。
1.3 为什么不选 Cesium、Babylon 或纯 Three.js
这个项目也考虑过 Cesium 和其他引擎,最终放弃是综合考量的结果。
Cesium 虽然三维地球和 GIS 能力很强,但做校园这种小范围、高精细度场景有点杀鸡用牛刀,而且它默认的坐标体系和渲染风格跟普通 Web 页面差异很大,想做出好看的智慧校园效果,定制成本比 Three.js 高不少。巴比伦 Babylon.js 在 WebGPU 支持和引擎整合度上其实很强,但国内社区资料和人才储备明显不如 Three.js,招人、查问题都费劲。
纯 Three.js 裸写当然也能做,但当项目里需要弹窗、表单、数据面板这些常见业务组件时,纯 Three.js 没有 DOM 层面的框架支持,做起来要从零开始造轮子。而 Vue3 + Three.js 的分工是:Three.js 专注于 WebGL 渲染和交互,Vue 3 负责业务组件和状态管理,各自在自己擅长的领域发力,这是这套组合的核心优势。
2. 场景数据与工程目录设计:先把地基打牢
2.1 一个清晰的 3D 场景工程目录长什么样
选好技术栈之后,最忌讳的就是一上来就写代码。做智慧校园这种大型场景,工程结构和数据组织没理清楚的话,后面加功能就是灾难。
我这里列一个实测下来很好用的目录结构:
src/ ├── components/ │ ├── CampusScene.vue # 3D场景容器组件 │ ├── InfoPanel.vue # 建筑信息弹窗 │ └── TagOverlay.vue # 标签叠加层 ├── composables/ │ ├── useThree.js # 场景初始化 │ ├── useScene.js # 模型加载与场景管理 │ ├── useControls.js # 轨道控制器封装 │ ├── useRaycaster.js # 点击拾取 │ └── useCampusData.js # 校园业务数据(教室状态等) ├── data/ │ ├── buildings.json # 建筑坐标与基础信息 │ └── classrooms.json # 教室状态数据 ├── models/ │ ├── campus.glb # 校园整体模型 │ └── building_detail.glb # 建筑细节模型 └── utils/ └── resize.js # 窗口自适应这套结构把 3D 渲染逻辑、业务数据、界面组件完全解耦。核心思路是把所有 Three.js 相关的代码封装在 composables 里,组件层只负责调用 setup,业务数据统一从 data 目录读取或通过接口请求。
2.2 用 JSON 配置驱动场景,而不是写死坐标
智慧校园场景里最麻烦的事情之一,是建筑点位、教室编号、设备位置这些信息非常多。如果把坐标点和建筑信息直接写死在代码里,后期调整一次得改一堆东西。
我的做法是维护一份 buildings.json 配置,每个建筑五个关键字段:id、name、position、rotation、scale。渲染层在加载模型时,根据这份配置决定每栋楼放在哪里、朝向哪里、多大尺寸。这样模型和配置分离的好处是,3D 美术或实施人员调模型时,前端只需要修改 JSON,代码不用动。
以数据驱动的方式组织场景,核心优势在于业务联动非常方便。比如后端返回了“A栋302教室今日出勤率95%”,前端拿到之后通过 id 找到场景中的对应标签,更新内容即可。
2.3 场景对象的分层管理策略
Three.js 的 Scene 是一个树状结构,管理不好就容易乱。我给智慧校园场景定位了这么几个层级:
Scene (根场景) ├── Lighting (环境光、平行光、点光源) ├── Terrain (地形、道路、操场) ├── Buildings (所有建筑的分组容器) │ ├── Building_A (分组内挂建筑模型的各个部件) │ └── Building_B ├── Trees (植被) └── Markers (标签、热点、特效层)这个分层策略的核心思想:用 Group 把同一类对象归组,方便对某一类对象做统一操作。比如你夜间模式要调整所有建筑的发光强度,直接遍历 Buildings 组就行;要给所有建筑加呼吸灯效果,也只需要在 Buildings 组里统一处理。
另外有一个实战经验,加载进来的 glTF 模型经常带有多层嵌套嵌套的 Group 层级,我一般在加载完成后先调用 scene.updateWorldMatrix(true, true) 再做坐标转换,同时尽量把模型内部的嵌套层级简化掉,能合并的几何体就合并,减少场景内节点的数量。
3. 从零搭起智慧校园 3D 场景的核心流程
3.1 Vue3 项目中集成 Three.js 基础环境
创建项目用的是 Vite,目前这是 Vue3 生态的最佳选择。命令很简单:
npm create vite@latest vue3-campus -- --template vue cd vue3-campus npm install three@0.160.0 npm install -D @types/three特别提醒一下,Three.js 在 r150+ 之后 API 变化比较大,比如 outputEncoding 改成了 outputColorSpace,不同版本的写法不通用,最好在 package.json 里锁一个版本。我这边的项目用的是 0.160.0,整个项目期间没出现 API 变动问题。
接下来封装 useThree.js。所有 Three.js 场景的第一步都是一个铁三角:Scene(场景)、Camera(相机)、Renderer(渲染器)。
import * as THREE from 'three' export function useThree(containerRef) { const scene = new THREE.Scene() scene.background = new THREE.Color(0x87CEEB) // 天空蓝 const camera = new THREE.PerspectiveCamera( 60, // 视角 window.innerWidth / window.innerHeight, // 宽高比 0.1, // 近裁面 1000 // 远裁面 ) camera.position.set(150, 120, 200) camera.lookAt(0, 0, 0) const renderer = new THREE.WebGLRenderer({ antialias: true, // 抗锯齿 logarithmicDepthBuffer: false }) renderer.setSize(window.innerWidth, window.innerHeight) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) renderer.shadowMap.enabled = true renderer.shadowMap.type = THREE.PCFSoftShadowMap if (containerRef.value) { containerRef.value.appendChild(renderer.domElement) } return { scene, camera, renderer } }这里面几个细节值得注意:PerspectiveCamera 的第一个参数越大视野越宽,智慧校园场景我用 60 度,主观视角刚好;近裁面 0.1、远裁面 1000 是基于校园模型尺度来的,如果场景特别大,远裁面还要调;setPixelRatio 限制在 2 是为了避免高分屏上每个像素都渲染导致性能爆炸。
3.2 渲染循环从哪来
Three.js 需要持续渲染才能实现动画和交互刷新的效果,常见的做法是用 requestAnimationFrame 驱动。但在 Vue3 项目里,渲染循环不能在组件里直接写个 while 循环,必须结合生命周期来做。
我在 useThree.js 里加了一段:
import { onBeforeUnmount } from 'vue' let animationId = null function startLoop(callback) { const loop = () => { animationId = requestAnimationFrame(loop) callback() } loop() } function stopLoop() { if (animationId) { cancelAnimationFrame(animationId) animationId = null } } onBeforeUnmount(() => { stopLoop() })startLoop 的 callback 里可以传一个函数,每次渲染帧时执行场景更新。比如在智慧校园项目里就会更新 OrbitControls、更新标签朝向、更新设备状态对应的颜色变化等。有一点必须提前想到:路由切换或组件销毁后一定要 cancelAnimationFrame,否则渲染循环不会停,White 屏、卡顿、内存泄漏都会接踵而来。
3.3 加载 glTF 模型:智慧校园的“面子”所在
校园场景的模型,一般由美术用 Blender、SketchUp 或者 Revit 制作,导出的格式我们统一用的 GLB(glTF 的二进制版),原因很简单:Three.js 原生支持、体积小、材质信息保留好、纹理可以直接内嵌。
加载模型用的是 GLTFLoader:
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js' const loader = new GLTFLoader() const dracoLoader = new DRACOLoader() dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.4.1/') loader.setDRACOLoader(dracoLoader) loader.load('/models/campus.glb', (gltf) => { const model = gltf.scene // 模型坐标修正 model.position.set(0, 0, 0) model.scale.set(1, 1, 1) scene.add(model) })这里要说一下 DRACOLoader 的用处。智慧校园的模型动辄二三十兆,直接加载非常慢。Draco 压缩是 Google 的网格压缩方案,可以把 GLB 文件压缩到三分之一甚至更小。但副作用是运行时需要有解压器代码,所以需要 setDecoderPath 指定解码器路径。
另外常遇到模型加载进来朝向不对、位置偏移、比例不对,这种问题在配置阶段解决比在代码里做矩阵变换要省事得多。如果美术用的建模软件和 Three.js 坐标系不一致(比如 Blender 是右手坐标系,Three.js 也是右手坐标系,但 Z 轴朝上还是 Y 轴朝上往往搞混),模型长宽高缩放不对会导致整个场景比例失调。
3.4 地表、植被和道路的处理技巧
校园场景的地表和大面积植被,不需要一栋楼一栋楼去建模,这里有两个我在项目里实测好用的技巧。
地表部分,先用一个大的 PlaneGeometry 铺底,然后加载一张卫星图或手绘校园平面图作为纹理,贴在地面上作为场景的空间定位基础。这个做法的好处是,后期模型摆放有明确的视觉参考,不用靠猜。
植被和小的装饰物,如果用单独模型会带来极大的性能压力。我这边是用 InstancedMesh 实现的,把一棵树的几何体加载一次,然后用 instanceMatrix 复制几十个实例放到不同位置,绘制调用从 50 次降到 1 次。种树时配合随机算法让树的旋转、缩放有点差异化,整体视觉上自然很多。
道路和跑道线这类线性元素,用 Three.js 的 Line 或 LineSegments 就能解决,不需要创建复杂的曲面。
3.5 光照与阴影:提升真实感的关键组合
默认的三维场景是没有光照的,加载出来的模型看起来“平”得不行。智慧校园场景里我用的是三光源组合:环境光 + 平行光 + 点光源。
- 环境光(AmbientLight)作为基础照明,把所有模型提亮到可见程度,强度大概 0.4;
- 平行光(DirectionalLight)模拟阳光,是产生阴影的主光源,强度 1.2 左右,位置从斜上方照射校园;
- 点光源(PointLight)在需要重点展示的体育馆、图书馆周围补充氛围光,视觉效果更丰富。
阴影这块要注意的是:阴影非常消耗性能,不需要每个模型都开 castShadow。我的做法是建筑和树木开 castShadow,地面和道路不开 receiveShadow 就不明显,反而会边缘异常变暗。平行光的方向需要多次试验,调太高会显得中午烈日,调太低又像黄昏,最终位置我调成了 (100, 150, 80)。
4. 业务功能落地:从“能看”到“能用”的关键跨越
4.1 OrbitControls 漫游的参数调优
场景建好之后,第一个需求几乎都是“能不能转”。Three.js 官方提供了 OrbitControls(轨道控制器),支持鼠标拖拽旋转、滚轮缩放、右键平移,是智慧校园项目最常用的控制器。
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' const controls = new OrbitControls(camera, renderer.domElement) controls.enableDamping = true // 开启惯性/阻尼效果 controls.dampingFactor = 0.08 // 阻尼系数 controls.maxPolarAngle = Math.PI / 2.2 // 限制俯仰角,避免转到地底下去 controls.minDistance = 20 // 最小缩放距离 controls.maxDistance = 500 // 最大缩放距离 controls.target.set(0, 0, 0) // 旋转中心这里最关键的是 enableDamping 设为 true,没有阻尼的话拖拽停止得特别生硬。还有一个细节:OrbitControls 会在控制器的变化事件里更新相机位置,所以必须在渲染循环里调用 controls.update(),否则阻尼效果不会生效。
智慧校园场景里我还给相机加了预设视角功能——全览、教学楼、宿舍区等几个关键位置一键跳转。实现方式是定义一个 CameraRoute 数组,切换动画时用 gsap 或者手写插值函数让相机平滑移动到目标位置。
4.2 Raycaster 点击拾取建筑与信息弹窗
能让用户点击建筑弹信息,是智慧校园从“3D 沙盘”走向“3D 应用”的关键功能。Three.js 点击检测的标准方案是 Raycaster(光线投射器)。
原理说起来很简单:从相机发射一条穿过鼠标位置的射线,检测射线与场景中哪些对象相交,取距离最近的交点。但实现起来有几个坑,逐一说一下。
首先,监听鼠标事件拿归一化坐标:
const raycaster = new THREE.Raycaster() const mouse = new THREE.Vector2() function onMouseClick(event) { // 将鼠标坐标转换为归一化设备坐标 (-1 到 1) mouse.x = (event.clientX / window.innerWidth) * 2 - 1 mouse.y = -(event.clientY / window.innerHeight) * 2 + 1 raycaster.setFromCamera(mouse, camera) const intersection = raycaster.intersectObjects(buildings.children) if (intersection.length > 0) { const target = intersection[0].object // 通过 userData 或场景节点 id 匹配业务数据 const buildingId = target.userData.id showBuildingInfo(buildingId) } }第二个关键是射线检测的性能。如果把整个 scene 传进去做检测,每次点击都会遍历所有网格,场景大时会卡顿。我的做法是专门维护一个 clickableObjects 数组,只有需要交互的建筑和热点对象放进去。
第三个是容易忽略的细节。检测到的 target 可能是建筑模型内部的某个子节点(比如一面墙、一个窗户),而不是整栋楼。所以需要给每个网格的 userData 设置 id,然后把模型的每个部件都打上父级建筑的 id,点击后逐级向上查找父节点,找到对应的建筑 id。
信息弹窗这块比较简单,Vue3 的 Teleport 组件很适合做:点击建筑后打开一个绝对定位的浮层,组件内部接受建筑信息 props,再配合 Vue Transition 做淡入淡出效果。
4.3 用 CSS2DRenderer 做建筑标签
智慧校园场景里每个建筑都要显示名称,比如“综合楼”“图书馆”。网上很多教程用 Sprite 做标签,我的经验是效果一般,文字不清晰,缩放也麻烦。推荐用 CSS2DRenderer——它是 Three.js 官方示例集里面的一个渲染器,本质是把 DOM 元素定位映射到三维空间坐标,类似一个 3D 空间里的 HTML 图层。
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js' const labelRenderer = new CSS2DRenderer() labelRenderer.setSize(window.innerWidth, window.innerHeight) labelRenderer.domElement.style.position = 'absolute' labelRenderer.domElement.style.pointerEvents = 'none' // 防止遮挡点击 container.appendChild(labelRenderer.domElement) // 给建筑添加标签 const labelDiv = document.createElement('div') labelDiv.className = 'building-label' labelDiv.textContent = '综合楼' const label = new CSS2DObject(labelDiv) label.position.set(0, 15, 0) // 悬浮在建筑上方 building.add(label)为什么选择 CSS2DRenderer 而不是 Sprite?因为标签是 HTML 写的,能用 CSS 控制样式,字体、颜色、圆角、动画都很好做,而且文字清晰度不受像素比影响。但要注意的是:CSS2DRenderer 渲染出来的 DOM 元素会覆盖在 WebGL 画布上,需要单独维护它的容器层级;给它设置 pointer-events 为 none,避免标签挡住用户点击场景的触发。
CSS2DRelatedObject 和 CSS2DObject 一样,最大的一个坑是在组件销毁时清不干净。我给每个标签加了 dispose 方法,路由离开时把 labelRenderer.domElement 从 DOM 树上移除,同时递归遍历 scene 移除所有 CSS2DObject,否则下一套场景会叠着上一套的标签。
4.4 用 Vue3 响应式数据驱动场景内的状态变化
智慧校园的核心业务能力在于“动态数据可视化”。比如教室的真实使用状态(空闲/占用/考试),如果能在 3D 场景上用不同颜色区分,管理者一眼就能看清全貌。
这块的实现思路是:Three.js 场景对象的数据源,直接对接 Vue3 的响应式数据。
const classroomStatus = ref({}) // 假设从接口定时拉取教室状态 async function fetchClassroomStatus() { const res = await api.getClassroomStatus() classroomStatus.value = res.data updateBuildingColors(classroomStatus.value) } function updateBuildingColors(statusMap) { Buildings.children.forEach((building) => { const id = building.userData.id const status = statusMap[id] const material = building.material if (Array.isArray(material)) { material.forEach((m) => { m.color.set(status === 'busy' ? 0xff4d4f : 0x52c41a) }) } else { material.color.set(status === 'busy' ? 0xff4d4f : 0x52c41a) } }) }每次数据更新时调用 updateBuildingColors,把对应建筑的颜色按状态改变。项目中我用了定时器,每 5 秒轮询一次接口,这样教室状态的刷新不需要刷新页面,实时性也能接受。
但这个方案有几个问题要注意。第一,不要直接把 Three.js 的 Object3D 对象塞进 Vue3 的 reactive 或 ref 里,Proxy 包装会导致性能下降,而且内部的 three 属性会被递归代理,出现不可预期的 bug。第二,更新颜色时,如果建筑有多个材质(数组),要逐个设置。第三,如果想做渐变过渡而不是瞬间变色,可以在渲染循环里逐步把当前颜色和目标颜色做 lerp 插值,效果会精致很多。
4.5 智慧校园项目的其他高频业务组件
除了上面这些,实际项目里一定还会用到以下几个功能,简单提一下实现思路。
监控点位联动:在场景的特定位置放置一个小的球体几何体或者视频图标贴图,点击后打开一个模态框,里面放入真实的 HLS/RTSP 视频流(通常通过 WebRTC 或者相关的转流服务来实现)。这本质上是 CSS2D 标签+弹窗的组合应用。
路径规划:用户点击两个建筑,需要生成校园内的行走路线,这个得结合后端的地理信息数据,在 Three.js 场景里画出 PathLine。用 LineGeometry 会出现线条太细的问题,可以用 TubeGeometry 或 LineSegments+LineBasicMaterial 的 linewidth(注意 linewidth 在大多数浏览器 WebGL 有上限,实际上是 1,所以用 TubeGeometry 更靠谱)。
告警提示:设备出现异常时,在场景中对应位置弹出一个红色闪烁感叹号。这种效果我一般用 Sprite 或者 CSS2D 元素,通过动画让透明度不断变化,UI 交互效果好,而且不会打断用户的主流程。
5. 踩坑实录:Vue3 响应式、纹理黑屏和打包体积
5.1 Vue 3 的 Proxy 响应式把 Three.js 对象“包坏了”
这是我在项目里踩的最深的一个坑。最开始很自然地写了:
const scene = reactive(new THREE.Scene())结果场景加载一切正常,但一旦调用 scene.add 或 scene.remove,就会报一堆类型错误。原因很简单:Vue3 的 reactive 是 Proxy 代理,Three.js 内部大量使用 this 引用自己,比如 scene.add 方法内部会调用 this.__addObject,而 Proxy 的 get 拦截会让 this 指向代理对象而不是原始对象,导致内部逻辑判断全部混乱。
解决办法也非常简单:第一,永远不要用 reactive 去包装 Three.js 实例,Scene、Mesh、Camera 这些都用普通变量。第二,需要响应式管理的只有业务数据(比如当前选中建筑 ID、教室状态映射),把这类数据用 ref 维护,场景对象通过调用方法来响应变化,形成一个单向数据流。这就够了。
5.2 纹理加载异步导致的“裸奔”和闪烁
项目过程中出现过模型加载进去后是暗淡的、没有贴图,过几秒后才出现贴图的情况,而且偶尔还会白屏闪烁。定位了半天,问题出在 Three.js 纹理加载是异步的,如果材质还没 ready 就开始渲染,画面自然不对。
处理方式有两个:一是统一用 LoadingManager 监听所有资源的加载进度,全部加载完成后才开始渲染循环;二是给模型设置 renderer.waitForImages = true,这个属性加上去之后渲染器会等纹理解码完成再绘制,闪烁问题基本消失。
加一个 LoadingManager 的进度条也很有必要,因为智慧校园模型大,体验上需要给用户一个加载中的反馈。否则模型没出来,用户直接关页面了。
5.3 路由切换后再进场景白屏:组件销毁没做干净
这个坑在多人协作时最容易出。Vue 组件路由切走了,但 Three.js 的场景、渲染器、控制器都还留在内存里,再次进入组件时又创建了一份新的,场景越堆越多,最终要么白屏要么帧率跌到谷底。
解决方案就是严格遵守一个原则:在 onBeforeUnmount 里把该释放的全部释放掉。关键代码:
onBeforeUnmount(() => { stopLoop() controls.dispose() renderer.dispose() // 遍历场景 Geometry/Material/Texture 并 dispose scene.traverse((obj) => { if (obj.geometry) obj.geometry.dispose() if (obj.material) { // 处理材质数组 if (Array.isArray(obj.material)) { obj.material.forEach(m => disposeMaterial(m)) } else { disposeMaterial(obj.material) } } }) labelRenderer.domElement.remove() renderer.forceContextLoss() })其中 renderer.forceContextLoss() 是压箱底的绝招,强制释放 WebGL 上下文,防止多次创建 Renderer 后浏览器因为上下文数量耗尽而白屏。我实测过,不调用这段代码,路由切个五六次之后必然出现问题。
5.4 打包体积从 8MB 缩到 1.8MB 的优化过程
首次 build 之后发现产物 8 兆多,加载体验很差。分析下来主要大头是 three.js 模块体积、GLB 模型文件体积、以及没有做代码分割。
第一步是模型优化。把 GLB 里用不到的动画、摄像机、灯光节点全部删掉,用 Draco 压缩后体积减掉一半多。第二步是对 Three.js 做 tree-shaking。用 import * as THREE from 'three' 的方式引入,其实大部分打包器无法对命名空间引入做很好的 tree-shaking,最好改成按需引入:
import { WebGLRenderer, Scene, PerspectiveCamera, Color, AmbientLight, DirectionalLight, PointLight, MeshStandardMaterial } from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'第三步是 Vite 配置手动分包:
// vite.config.js build: { rollupOptions: { output: { manualChunks: { three: ['three'], 'three-addons': ['three/examples/jsm/controls/OrbitControls.js', 'three/examples/jsm/loaders/GLTFLoader.js'] } } } }这样基础包和 Three.js 单独拆开,配合 CDN 缓存策略,用户二次访问时首屏几乎秒开。
6. 性能优化的几个实操点:从 30 帧到 60 帧的调整
6.1 合并几何体与绘制调用
先说说对性能影响最大的一个指标:Draw Call(绘制调用次数)。智慧校园场景如果建了 100 栋楼,每栋楼是独立 Mesh 的话,渲染一帧的 Draw Call 就有 100+,加上地形、树木、路灯等,轻轻松松几百次。浏览器每帧能承受的 Draw Call 数量是有限的,到了瓶颈就会严重掉帧。
最有效的优化是合并几何体:把静态的、材质相同的模型部分用 BufferGeometryUtils.mergeGeometries 合成一个大几何体。比如路边的路灯,把几百个路灯合并成一个 Mesh,材质还保持不变,渲染开销瞬间降下来。
但要注意合并是有代价的:合并后就不能单独控制每个物体了,也无法做独立的点击拾取。所以我对智慧校园的策略是:可交互的建筑不合并,保证点击检测方便;纯装饰的路灯、绿化、栅栏这些不交互的对象全部合并。
6.2 纹理图集与压缩
智慧校园项目里用到的纹理贴图非常多,教学楼的外墙、玻璃、地面、草坪,如果每张纹理都单独加载,会占用大量显存,切换材质时也会卡顿。
我的做法是尽量用纹理图集(Texture Atlas),把多张小图拼到一张大图上,这样材质只需要一个贴图资源。配合 Texture.anisotropy 设置各向异性过滤,远处的地面纹理不会模糊到没法看。
同时记得压缩图片格式:jpg 格式适合摄影图,png 适合透明贴图,能转 WebP 的场景尽量转 WebP。大尺寸纹理(2048x2048 以上)要考虑分辨率是否真的被看到,校园场景大部分贴图 1024 就够,这个细节可以省很多显存。
6.3 LOD 细节层次与视野裁剪
校园很大,用户站在东门的时候看不到南门的细节。给每个建筑加 LOD(Level of Detail,细节层次),就是根据相机距离切换模型的精细程度,远的建筑用低模,近的建筑用高模。Three.js 提供了 LOD 类,实现起来不复杂:
const lod = new THREE.LOD() lod.addLevel(highDetailModel, 0) lod.addLevel(midDetailModel, 100) lod.addLevel(lowDetailModel, 200) scene.add(lod)再配合相机视锥体自动裁剪(Frustum Culling),Scene 会自动忽略视野外的对象。默认的 Box3 包围盒计算在多节点模型上可能不准确,我习惯手动设置 mesh.frustumCulled 相关属性,或者调用 computeBoundingSphere 重新计算。
6.4 限制像素比和后期效果
高分屏上 setPixelRatio 设置太高会导致像素填充量爆炸。我统一做了 clamp,把上限设为 2,一般项目就够了。
后期处理是视觉提升的神器,But 也是性能杀手。UnrealBloomPass 泛光效果加上去,整个场景的科技感会强很多,但移动端低端设备会直接卡到没法用。我的策略是检测硬件(通过 navigator.hardwareConcurrency 或者 WebGL 的 debugRendererInfo 里的 UNMASKED_RENDERER_WEBGL 查看 GPU),如果是低端设备就自动关闭后期特效,只保留基础渲染。
整个项目从选型到上线,前前后后折腾了差不多两个月。现在回想,最有价值的经验其实就两条:一是场景对象和业务数据严格分离,Three.js 的对象不要碰 Vue 的响应式系统,业务状态用 ref 单独管;二是组件销毁时那套 dispose 代码一定要写彻底,白屏和内存泄漏基本都是这个原因。如果你的项目也要做校园、园区、工厂这类 3D 可视化场景,按照这篇文章的路径走一遍,至少能少踩掉七成的坑。
最后再分享一个我个人的小习惯:调试 3D 场景时,我永远会在项目中加一个 stats 插件,开着浏览器后台跑一会儿,观察 Draw Call 和三角形的数字变化。这三个数字跟真实用户体验的关联度非常高——当 Draw Call 超过 300、三角形超过 200 万的时候,帧率几乎必然下降。性能优化别等最后才做,从项目一开始就要记账,心里有数,后面调起来才不慌。
本文还有配套的精品资源,点击获取