简介面向Vue3与three.js开发者的智慧校园3D场景实践资源提供可自由旋转、缩放、切换视角及模型自动旋转的校园三维模型并支持在场景内播放视频适合需要快速搭建3D展示平台的前端开发者参考。压缩包共142个文件包含38个js、34个png、22个jpg、15个glb、7个gltf以及vue、glsl、wasm、mp4等类型对应场景构建、纹理贴图、模型资源与交互动画等部分整体约193.42MB。目前已有1497人学习下载。资源内含完整的Vue3工程与三维模型文件可直接安装依赖后启动项目便于对照源码理解场景搭建、相机控制、动画交互和视频嵌入等实现思路同时通过模型文件与目录结构可快速定位不同区域的3D内容用于二次开发或教学演示。1. 项目概述与整体思路拆解前阵子做了一个基于 vue3 three.js 的智慧校园 3D 可视化项目从模型加载、场景搭建到和后端数据联动整体走通了一遍过程中踩了不少坑也沉淀了一些可复用的经验。这篇博文把关键环节拆开聊聊从工程搭建讲到功能落地再到性能优化和问题排查给准备做同类项目的朋友一个可以直接参考的路子。这个项目最终实现的效果是这样的打开页面后一座完整的校园 3D 微缩场景呈现在浏览器里包括教学楼、实验楼、宿舍楼、食堂、体育馆、绿化、道路等基础建筑用户可以用鼠标拖拽旋转视角、滚轮缩放点击任意一栋楼就能弹出对应楼层的信息面板面板里展示教室数量、用电量、人流量等实时数据左侧还有楼层切换按钮可以单独查看某一层内部的房间分布和设备位置。整体技术栈就是 vue3 全家桶加 three.js 加 Element Plus 做后台界面模型由建模师在 3ds Max 里制作导出 GLB 格式供前端使用。哪些人适合读这篇内容如果你正在用 vue3 做 Web 3D 类的项目或者刚接触 three.js 想知道它怎么和主流前端框架结合再或者你只是听说过“数字孪生”“智慧校园”这些词想了解实际落地的技术路径那这篇文章应该能提供不少有价值的参考。需要说明的是这里讲的核心方案不是唯一解比如模型你用 Blender 建模导出也行场景你用 TresJS 这种面向 Vue 的 three 封装库也行但底层原理是相通的。我先把最通用、最可控的原生 three.js 方案讲透你理解了这套逻辑再去套用任何现成框架都会顺手很多。1.1 智慧校园 3D 场景到底在做什么“智慧校园”这个概念这几年被提得很多但落到前端工程师手上本质就三件事三维可视化、数据绑定、交互操作。三维可视化是把现实校园的建筑结构一比一地搬进浏览器数据绑定是把后端推送的教室占用情况、设备运行状态、能耗数据等挂到对应的 3D 对象上交互操作则是让用户能点选、旋转、缩放、切换楼层以最直觉的方式获取信息。做这种项目最怕的一件事情就是做成“静态楼盘展示”模型再精细如果不能和数据联动价值就少了一大半。所以我在项目里把视觉展示和业务逻辑分开处理three.js 负责“看得到”的部分vue3 的响应式状态管理负责“摸得着”的部分两者通过事件和数据的双向绑定打通这比传统上把 everything 都塞进 three 的 update 循环里要清晰得多。1.2 技术选型为什么是 vue3 搭配 three.js先说为什么不选原生 JavaScript 直接写 three.js。项目里有大量 UI 组件、弹窗、数据面板、权限控制如果不用框架这些部分代码会非常散乱。而 Vue3 的组合式 API 恰好非常适合和 three.js 配合——你可以在 setup 里管理场景、相机、渲染器的生命周期用 onMounted 和 onBeforeUnmount 精确控制 three 资源的创建与销毁比 Vue2 的 options API 处理这类“非响应式但需要生命周期管理”的第三方库舒服太多。我在这个项目里也用到了 Vue2 和 Vue3 最核心的一个差异响应式代理。Vue3 用 Proxy 实现了更彻底的响应式这使得 three.js 场景中一些对象属性比如相机位置、楼栋高亮状态可以和界面状态直接绑定。你改了一个 ref 的楼层索引对应楼层在 3D 场景中就自动切换透明度这种体验在 Vue2 里实现起来要绕不少弯子而在 Vue3 里就是常规操作。three.js 本身就不用多解释了它是 WebGL 生态里最成熟的库封装了渲染器、场景图、相机、光照、模型加载等大量底层细节你不需要懂着色器编译也能写出看着还不错的 3D 场景。而且它有完整的 GLTF 模型加载支持对智慧校园这种需要外部建模的项目来说有 GLTFLoader 基本就解决了“美术和前端交接”的协作问题。2. 环境准备与工程搭建2.1 用 Vite 快速创建 vue3 项目我用 Vite 而不是 Vue CLI原因很直白——Vite 基于 esbuild 的开发服务器冷启动和热更新都比 webpack 快一个量级对于 three.js 这种动辄几 MB 的模型文件开发阶段的重载体验差别非常大。npm create vitelatest campus-3d -- --template vue cd campus-3d npm install npm install three装完 three 之后我建议顺手看一下它的构建产物路径因为不同版本的 three 在 import 方式上稍有差异。官方现在推荐直接import * as THREE from three这样会走 ESM 按需打包。如果你需要加载 GLTF 模型再装一个npm install types/three npm install three/examples/jsm/loaders/GLTFLoader.js第二行这种写法在 TS 环境里需要显式声明模块但在 JavaScript 项目里直接用没问题。后面我们用three/addons/...这种简写路径Vite 配了别名才更顺手。2.2 目录结构与基础架构设计这个项目规模不大但我依然把 three 相关的代码和 vue 组件做了拆分避免一个文件写到 1000 行然后谁也读不下去。src/ components/ CampusScene.vue # 3D 场景容器组件 BuildingInfoPanel.vue # 楼栋信息弹窗 FloorSwitcher.vue # 楼层切换器 three/ sceneManager.js # 场景、相机、渲染器、灯光管理 modelLoader.js # GLTF 模型加载与返回值处理 interaction.js # 射线拾取、鼠标事件、点击回传 animationLoop.js # 渲染循环 stores/ campus.js # Pinia 状态存当前选中楼栋、楼层我当时把sceneManager.js设计成一个类初始化时接收 canvas 容器 DOM然后内部创建场景、相机、渲染器、灯光和轨道控制器。modelLoader.js只负责加载模型并返回模型组interaction.js负责给场景绑定点击事件。这样每个文件职责单一后面做楼层切换或者换了新模型改动范围都被限制在各自文件里。注意three 的 scene 对象不要直接丢进 Vue 的 ref 里做响应式代理。three 内部对象有大量循环引用和 TypedArray一旦被 Proxy 代理会触发性能问题甚至直接报错。正确的做法是保持纯 JS 引用触发视图更新时只控制几个普通变量。3. 核心场景搭建与 3D 实现细节3.1 初始化场景、相机、渲染器这部分是 three.js 的“基本功”但不同项目的参数选择差异很大。智慧校园的场景特征是模型范围大几十栋建筑、视角以俯视微倾斜为主、用户主要在看建筑外立面和局部内景。基于这些特征我做了以下配置import * as THREE from three; import { OrbitControls } from three/addons/controls/OrbitControls.js; export class SceneManager { constructor(container) { this.scene new THREE.Scene(); this.scene.background new THREE.Color(0x1a2a3a); this.camera new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 2000); this.camera.position.set(80, 120, 200); this.camera.lookAt(0, 0, 0); this.renderer new THREE.WebGLRenderer({ antialias: true }); this.renderer.setSize(container.clientWidth, container.clientHeight); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.shadowMap.enabled true; container.appendChild(this.renderer.domElement); this.controls new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping true; this.controls.dampingFactor 0.08; this.controls.maxPolarAngle Math.PI / 2.3; this.controls.minDistance 20; this.controls.maxDistance 500; } }几个参数我解释一下为什么要这么设。视角用 45 度而不是更广的 60 或 75 度因为校园模型的体量大如果 FOV 太大边缘建筑的透视畸变会很明显看起来像鱼眼镜头影响“沙盘式展示”的观感。相机位置我习惯先设成一个稍微偏高、带轻俯角的角度保证用户一进来就能看到一个比较完整的校园布局。maxPolarAngle设置为 90 度左右多一点防止用户把视角转到地面以下变成仰视或穿模视角。阴影这里要特别提醒three.js 默认不开阴影开了之后如果场景里建筑模型面数很高渲染开销会急剧上升。智慧校园这种室外大场景我建议只给主楼、地标建筑开接收阴影其他建筑不要开或者干脆只在拍摄效果图上开实时渲染还是以性能优先。3.2 校园模型加载GLTFLoader 和 Draco 压缩模型格式我用的是 GLBGLTF 的二进制版本。原因很实际GLB 把网格、纹理、材质打进一个文件不用处理资源路径问题而且 three.js 官方对 GLTF 的支持最完整不会出现 OBJ 那样材质需要单独维护的情况。如果建模师用的是 3ds Max导出时直接选 glTF 格式大部分情况下会得到 GLB 文件注意让建模师把单位设为米因为 three.js 默认以“米”为世界单位如果模型是用厘米导出的到场景里会突然大 100 倍坐标全乱。import { GLTFLoader } from three/addons/loaders/GLTFLoader.js; import { DRACOLoader } from three/addons/loaders/DRACOLoader.js; export function loadCampusModel(scene, url) { return new Promise((resolve, reject) { const loader new GLTFLoader(); const dracoLoader new DRACOLoader(); dracoLoader.setDecoderPath(https://www.gstatic.com/draco/versioned/decoders/1.5.6/); loader.setDRACOLoader(dracoLoader); loader.load( url, (gltf) { const model gltf.scene; model.traverse((child) { if (child.isMesh) { child.castShadow true; child.receiveShadow false; } }); scene.add(model); resolve(model); }, (progress) { const percent (progress.loaded / progress.total) * 100; console.log(模型加载进度: ${percent.toFixed(2)}%); }, (error) reject(error) ); }); }如果你拿到的 GLB 模型较大超过 30MB务必让美术同事在建模软件里先做减面处理再考虑用 Draco 压缩。Draco 能大幅缩小模型体积比如 50MB 压到 15MB 甚至更低但代价是加载后需要额外时间解码移动端上解码慢会更明显所以体积和耗时之间要平衡。常见的坑模型里的纹理如果是中文文件名或者路径里有中文目录在部分浏览器上会找不到贴图导致模型变紫、变灰。让美术同事输出时使用英文字母和数字命名能省掉很多莫名其妙的问题。3.3 灯光与环境让 3D 校园看起来不“灰”很多第一次搭 three 场景的朋友模型加载进来了但画面灰蒙蒙的原因就是灯光没配好。three 默认的材质需要用环境光和方向光共同作用才能表现建筑的立体感。我在这项目里用了三路光const ambientLight new THREE.AmbientLight(0xffffff, 0.4); scene.add(ambientLight); const dirLight new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(100, 150, 80); dirLight.castShadow true; scene.add(dirLight); const hemiLight new THREE.HemisphereLight(0xfff1e0, 0x334455, 0.6); scene.add(hemiLight);半球光的作用很多人会忽视它的原理是从天空和地面两个方向分别打光模拟的是大气环境反光加完之后建筑暗部不会死黑色调也更自然。如果项目走的是写实风格我推荐 HB半球光 方向光 环境光的组合如果是年轻化的科技风格比如蓝色调为主可以把环境光颜色调成偏蓝同时把 Three 的scene.fog加上远处建筑有一些空气透视感视觉层次会丰富很多。3.4 相机控制与鼠标拾取点击楼栋弹信息窗智慧校园这种项目“点击建筑看信息”是最核心的交互。three 里的实现思路是 Raycaster 射线检测——从相机位置穿过鼠标点击位置发一条射线检测它撞到场景里的哪些物体。import * as THREE from three; import { ref } from vue; export function useBuildingClick(camera, scene, model, onPick) { const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); const buildings []; model.traverse((child) { if (child.isMesh child.userData.buildingId) { buildings.push(child); } }); const onClick (event) { const rect renderer.domElement.getBoundingClientRect(); pointer.x ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y -((event.clientY - rect.top) / rect.height) * 2 1; raycaster.setFromCamera(pointer, camera); const intersects raycaster.intersectObjects(buildings, false); if (intersects.length 0) { onPick(intersects[0].object.userData.buildingId); } }; renderer.domElement.addEventListener(click, onClick); }这里有个细节容易踩坑如果你的 GLB 模型被建模师在组件里嵌套了好几层 groupintersectObjects 的第一个参数填的是 buildings 数组第二个参数必须设为 false因为你已经手动把内部 mesh 全部拍平放进数组了再递归反而会重复检测、浪费性能。另外射线命中后取到的 object 最好通过 userData 传递业务 ID不要直接用 object.name因为名字可能是Mesh_0234这种建模软件生成的玩意儿不具备业务意义。我在建模阶段就让美术给每个建筑设置了一个自定义属性 buildingId对应楼栋主键。导出 GLB 后three 会把自定义属性放进 userData前端代码直接读取就行省去硬编码坐标映射的麻烦。4. 数据联动与功能模块实现4.1 楼层切换的核心思路智慧校园场景里楼层切换是一个“看起来简单但处理起来很烦”的功能。如果模型把每栋楼的每一层、每间房都做了独立 mesh切换楼层时有两种常见方案方案一直接改 visible 属性。把当前楼栋其他楼层的 mesh 隐藏掉只显示目标楼层。优点是开销极小操作即时缺点是如果楼层的墙体、地板和楼梯等共享了同一份材质或合并过网格就没法单独隐藏了。方案二用透明度渐变配合发光线框。保留楼层外形把目标楼层高亮其他楼层半透明显示这样既能看到楼层内部结构又不丢失整体空间关系。我做的是把非目标楼层材质设成 transparentopacity 降到 0.15同时给目标楼层增加一个亮色的描边或者 Box。function switchFloor(buildingMeshes, activeFloor) { buildingMeshes.forEach((mesh) { if (mesh.userData.floor activeFloor) { mesh.material.transparent true; mesh.material.opacity 1; mesh.material.depthWrite true; } else if (mesh.userData.floor activeFloor) { // 低楼层半透明方便看到内部 mesh.material.transparent true; mesh.material.opacity 0.15; mesh.material.depthWrite false; } else { // 高楼层直接隐藏减少渲染负担 mesh.visible false; } }); }这个逻辑的关键在于建模时就得给每层 mesh 设置 floor 属性。如果建模师没做前端就得通过坐标高度来计算楼层信息——也不是不行但会脆弱得多换个模型就崩。所以做这个功能之前先和美术定一个建模规范包括房间命名规则、楼层属性字段、楼栋 ID 命名方式这比后端接口对接还重要。4.2 标签标注Sprite 还是 CSS3D给建筑添加名称为“第一教学楼”“图书馆”等标注我试过两种方案。Sprite 是在 three 场景内实现的色块 文字纹理好处是始终面向相机、跟随模型移动缺点是文字分辨率固定放大视角后会模糊而且颜色和边缘锯齿调起来很费劲。CSS3DRenderer 是把 HTML 元素放到 3D 坐标上文字清晰但是需要单独维护一套渲染器和 WebGL 渲染器还要做 z-index 对齐逻辑会变得更重。我的选择是楼栋名称用 CSS2DRendererthree 官方提供CSS2DObject 可以把普通 div 放到 3D 坐标因为它文字量少、清晰度高、样式调整也灵活。import { CSS2DRenderer, CSS2DObject } from three/addons/renderers/CSS2DRenderer.js; const label document.createElement(div); label.className building-label; label.textContent 图书馆; const labelObj new CSS2DObject(label); labelObj.position.set(10, 20, 10); model.add(labelObj);注意 CSS2DRenderer 的 DOM 层级要手动调到 canvas 上方并且监听 resize 时也要同步更新尺寸。它的元素是覆盖在 WebGL canvas 上的所以不会受 three 镜头雾效影响在智慧校园这种项目里展示效果反而更干净。4.3 把后端数据接到 3D 场景数据接入这块我用的是 Pinia 管理全局状态。后端返回的校园数据里有楼栋名称、楼层数、实时人数、用电量、温度等字段我把这些字段和模型中的 buildingId 做映射点击建筑时通过 buildingId 从 store 里取数据渲染到信息面板中。// stores/campus.js import { defineStore } from pinia; export const useCampusStore defineStore(campus, { state: () ({ campusData: [], selectedBuilding: null, selectedFloor: 1, }), actions: { setSelectedBuilding(buildingId) { this.selectedBuilding this.campusData.find(b b.id buildingId) || null; }, }, });如果后端数据是实时变化的比如能耗曲线、人流量监控建议不要用 WebSocket 传高频全量数据。实际项目里我是用轮询每隔 5 秒拉一次有变化的楼栋数据然后调用一个高亮或颜色映射函数更新 three 对象外观。这样数据刷新频率低三渲二也不会过于消耗性能。颜色映射的做法很直观给每栋楼定义一个能耗阈值比如绿色表示正常、黄色表示偏高、红色表示告警。更新时就遍历楼栋 mesh修改对应材质的颜色值。为了平滑过渡我给材质加了color.lerp的插值视觉上颜色渐变而不是突然跳变。5. 性能优化与踩坑记录5.1 性能瓶颈排查和优化手段智慧校园模型动辄十几万面不加优化普通笔记本跑起来会明显掉帧。我实际用下来有几个立竿见影的优化手段。合并静态几何体是最基本的一招。如果场景里大量相同形状、相同材质的物体比如同款路灯、树、桌椅用BufferGeometryUtils.mergeGeometries把它们合并成一个网格DrawCall 数会降很多。three.js 的渲染开销和 DrawCall 数量强相关合并前 300 个 DrawCall 合并后降到 40 个帧率能翻倍。其次是纹理压缩。GLTF 模型默认带的是 png 或者 jpg 贴图高分辨率贴图在移动端是个灾难。我用 gltf-transform 这个命令行工具对纹理做了转 WebP 和 resize 处理在视觉损失可接受的情况下模型体积缩小了差不多一半。5.2 白屏、模型错位、内存泄漏的排查方法我在这里把项目里遇到过的几个经典问题列出来方便你遇到同样问题时有排查的方向。第一个是 canvas 白屏。这个通常是 WebGL 上下文创建失败导致的可能是 GPU 进程不可用也可能是浏览器硬件加速被关闭。排查方法是打开浏览器控制台看有没有 WebGL 报错如果是WebGL not supported之类的信息建议降级方案做一个 2D 平面地图来替代 3D 场景让用户不至于什么都看不到。第二个是模型位置错乱或尺寸不对。90% 的原因是单位不一致美术用厘米前端按米加载模型就大 100 倍。这种问题没法靠改 camera 位置解决正确做法是和美术统一建模单位或者在前端加载模型后model.scale.set(0.01, 0.01, 0.01)做一次手动缩放。第三个是内存泄漏问题在 Vue3 里常见于组件卸载时没有正确清理 three 资源。onBeforeUnmount 里必须做四件事移除 renderer.domElement调用renderer.dispose()调用scene.traverse遍历所有 mesh 并 dispose 几何体和材质移除事件监听器。漏一步长期切换页面都会导致页面越来越卡。6. 常见问题速查表这个项目整体排下来我把遇到频率最高的问题整理了一张表新接手 three.js Vue3 项目的朋友可以收藏一下现象可能原因解决方案模型加载失败控制台 404GLB 文件路径写错或没有放到 public 目录放到 public/models 目录使用相对路径引用模型显示为黑色灯光没有配置够或者材质无光照属性增加环境光、方向光确认材质不是 MeshBasicMaterial模型加载完但视角全是白色背景没有设置场景背景色或雾设置scene.background和scene.fog点击建筑无响应Raycaster 检测的网格数组不对检查 intersectObjects 参数是否包含对应 mesh切换楼层后旧楼层残留没有正确隐藏非目标楼层 mesh遍历模型设置 visible 或 opacity并调用 renderer.render 更新页面卡顿掉帧DrawCall 过多纹理太大合并几何体、压缩纹理、降低 pixelRatiovue3 组件销毁后动画仍运行requestAnimationFrame 循环没有停止在 onBeforeUnmount 里 cancelAnimationFrame并 dispose 渲染器移动端屏幕旋转后布局错乱没有监听 resize 事件在 window resize 中更新 camera 纵横比、renderer 尺寸7. 实际项目复盘与扩展思路7.1 从开发流程角度给三点建议第一个建议先搭一个最小可行的场景跑通再往上加细节。很多项目一开始就追求模型精细度结果前端和美术在互相等对方战线拉得很长。我这次是先做一个“占位盒子”组成的校园雏形把点击、切换楼层、数据联动这些交互逻辑全部跑通后再让美术替换成高精度模型。这样前端开发不被美术资源卡住美术也不用等前端框架到位才开始建模。第二个建议three.js 业务逻辑尽量做成纯 JavaScript 模块不要全塞进 Vue 组件。Vue 组件负责渲染 UI 和接收事件场景初始化、模型加载、射线检测等归入独立模块。这样单测好写后续换框架也能迁移复用。第三个建议交互规范要提前定好。智慧校园项目经常会遇到多端适配PC 上鼠标拖拽旋转移动端手势旋转而且还要兼容列表点击和 3D 场景点击两种操作路径。提前把交互状态机定义好后续再做就不容易出幺蛾子。7.2 还可以扩展什么这个项目的路子走通之后可以向两个方向延伸。一个是往游戏引擎或渲染后处理走加一些水体反射、粒子效果、天气切换提升视觉表现力做演示和宣传时特别有用。另一个是往数据可视化纵深走把能耗曲线、楼栋三维数据叠加进场景里接入大屏展示模式。我在实际操作中的体会是这套 vue3 three.js 的组合能承担的不只是校园可视化园区、厂房、博物馆、商业综合体等凡是需要“空间 数据”结合的场景都可以用同一套架构差别只是模型和业务字段变了。掌握了这套从建模规范、工程拆分、交互设计到数据联动的路线后续复制到任何数字孪生项目里都会轻松很多。如果这个项目做了升级迭代建议优先补一个场景编辑功能让非程序员也能在界面上摆放建筑、配置热点区域这对项目交付和后期维护帮助巨大。最终落到实际项目里技术永远是手段能不能让看的人一眼抓住重点、用起来顺手才是真正拉开差距的地方。本文还有配套的精品资源点击获取