简介这是一份面向WebGL与Three.js进阶开发者的智慧城市3D可视化项目源码适合希望把图形渲染技术落地到城市级场景的前端与可视化工程师参考。项目围绕地图地理数据加载、建筑与道路三维建模、光照材质、动画交互、实时数据可视化等环节展开并涉及LOD、批处理渲染等性能优化思路以及响应式布局与UI设计对理解智慧城市应用的完整实现链路有较高参考价值。压缩包共60个文件约3.06MB以23个js脚本承载核心逻辑与工具库配合10个css与1个html搭建界面另有16个png、8个jpg贴图及1个wasm解码模块覆盖天空盒、水面、建筑等纹理资源。目前已有3927人学习下载可帮助读者对照源码梳理目录结构、理解模块拆分与渲染流程并借鉴其数据可视化与交互实现方式适合作为课程设计或项目练手的参考实例。1. 智慧城市可视化为什么总在“看着炫、跑着卡”之间反复横跳做过三维可视化大屏的同行大概都有过这种体验演示阶段帧率稳在 60一上真实数据、一叠加图层、一开后期特效浏览器风扇就开始起飞帧率掉到个位数。智慧城市应用尤其如此——它天然要同时扛住建筑白模、路网、水系、POI 点位、实时车流、告警弹窗这几类负载任何一类没处理好整块画布都会跟着遭殃。WebGL 加 Three.js 这套组合之所以成为主流选择不是因为它是唯一方案而是因为它在“浏览器原生、无需插件、生态成熟”这三个约束下几乎没有对手。这篇笔记围绕一个智慧城市方向的 Three.js 源码实例展开把场景组织、数据驱动、性能取舍和落地路径拆开讲清楚。适合已经会写基础 Three.js 场景、但一上规模就翻车的开发者也适合想评估“这套东西到底值不值得投入”的技术负责人。2. 智慧城市场景的骨架怎么搭从坐标系到图层分组2.1 先定坐标系和单位别等模型进来才后悔智慧城市项目最容易埋雷的地方不是渲染而是坐标。常见做法是地理数据用经纬度三维模型用米而 Three.js 默认单位是“无量纲”。这三者一旦混用就会出现模型飘到天上去、点位和建筑对不上、相机近远裁剪面怎么调都不对的情况。我一般会在项目初始化阶段就固定一套约定以城市中心某点为原点X 轴朝东、Z 轴朝南或朝北团队内统一即可Y 轴朝上单位统一为米。经纬度转平面坐标用等距圆柱投影或简单的墨卡托近似在几公里范围内误差可以接受。// 坐标约定原点取城市中心单位米X 东 Z 南 Y 上 const ORIGIN { lng: 116.40, lat: 39.90 }; // 仅作示例占位实际替换为项目中心点 const METERS_PER_DEG_LAT 111320; function lngLatToWorld(lng, lat) { const dLng lng - ORIGIN.lng; const dLat lat - ORIGIN.lat; // 经度方向要乘 cos(lat) 修正否则东西向会被拉长 const metersPerDegLng METERS_PER_DEG_LAT * Math.cos((ORIGIN.lat * Math.PI) / 180); return { x: dLng * metersPerDegLng, z: -dLat * METERS_PER_DEG_LAT, // 纬度越大越靠北Z 取负 }; }这段代码的关键在Math.cos那一行。很多新手直接拿经纬度差值乘固定系数结果城市东西向被拉伸路网和建筑永远对不齐。参数上METERS_PER_DEG_LAT取 111320 是常用近似值纬度方向误差很小经度方向必须按当前纬度做余弦修正纬度越高修正越明显。原点ORIGIN建议从后端配置下发不要硬编码在多个文件里否则后期换城市就是一场灾难。2.2 图层分组把“能一起开关的东西”放进同一个 Group智慧城市场景动辄几十个图层如果全部平铺挂在 scene 下后期做显隐控制、射线拾取、性能裁剪都会非常痛苦。我的习惯是按业务语义分组底图组、建筑组、路网组、水系组、点位组、特效组。每个组用THREE.Group包一层组名用英文常量方便调试时在控制台直接scene.getObjectByName(BUILDINGS)。const LAYERS { BASE: BASE, BUILDINGS: BUILDINGS, ROADS: ROADS, WATER: WATER, POI: POI, EFFECT: EFFECT, }; function createLayer(name) { const group new THREE.Group(); group.name name; group.matrixAutoUpdate false; // 静态图层关掉自动矩阵更新省 CPU return group; } const buildingLayer createLayer(LAYERS.BUILDINGS); scene.add(buildingLayer);matrixAutoUpdate false这一行是血泪经验。建筑、路网这类静态图层位置一旦确定就不会再变关掉自动更新能省下每帧的矩阵重算开销。代价是你手动改position后必须调用updateMatrix()所以只对确定不动的图层用。点位、车辆这类动态对象千万别关否则它们会“卡”在原地不动排查起来很玄学。2.3 建筑白模的合批为什么你的城市一多就掉帧建筑是智慧城市里数量最大的对象。一栋楼一个 Mesh一万栋就是一万次 draw call浏览器直接跪。常见做法是合并几何体把同材质、同贴图的建筑合并成一个BufferGeometry用mergeGeometries或自己拼顶点。合并后 draw call 从一万降到个位数帧率立刻回来。import { mergeGeometries } from three/examples/jsm/utils/BufferGeometryUtils.js; function buildCityMesh(buildings) { const geometries []; for (const b of buildings) { // 每栋楼用一个 BoxGeometry按底面中心和高宽深摆放 const geo new THREE.BoxGeometry(b.width, b.height, b.depth); geo.translate(b.x, b.height / 2, b.z); geometries.push(geo); } const merged mergeGeometries(geometries, false); const material new THREE.MeshLambertMaterial({ color: 0x2a4a6b }); return new THREE.Mesh(merged, material); }mergeGeometries的第二个参数useGroups传false表示所有几何体共用一组材质如果你需要不同楼不同颜色就得传true并配合材质数组但那样 draw call 又会涨回去。所以实践中更推荐用顶点色或一张调色贴图来区分楼栋而不是拆材质。合并的代价是失去了单栋楼的独立变换能力如果业务需要“点某栋楼高亮”就得靠射线拾取拿到面索引再反查是哪栋楼——这一步要在合并前记录每栋楼的顶点范围否则拾取回来你也不知道点的是谁。3. 数据驱动渲染让后端数据真正“流”进场景3.1 点位数据的批量渲染InstancedMesh 的正确打开方式POI、摄像头、传感器这类点位数量可能上千。用普通 Mesh 一个个加draw call 又是一场灾难。InstancedMesh是标准解法一次 draw call 渲染成千上万个实例每个实例有自己的变换矩阵。function createPOILayer(points, iconGeometry, iconMaterial) { const mesh new THREE.InstancedMesh(iconGeometry, iconMaterial, points.length); const dummy new THREE.Object3D(); points.forEach((p, i) { const { x, z } lngLatToWorld(p.lng, p.lat); dummy.position.set(x, p.height || 0, z); dummy.scale.setScalar(p.scale || 1); dummy.updateMatrix(); mesh.setMatrixAt(i, dummy.matrix); }); mesh.instanceMatrix.needsUpdate true; // 必须手动标记否则不生效 return mesh; }instanceMatrix.needsUpdate true是新手最常漏的一行。不写它实例矩阵改了也不会上传 GPU表现就是“数据更新了但画面没变”。另外InstancedMesh的拾取要用raycaster.intersectObject后读instanceId不是普通 Mesh 的faceIndex。如果你的点位需要单独变色比如告警点变红用setColorAt配合instanceColor比拆成两个 Mesh 高效得多。3.2 实时车流用插值而不是每帧重算路径车流是智慧城市里最考验性能的动态元素。常见翻车写法是每帧重新计算每辆车的路径点几千辆车直接把主线程占满。正确做法是路径预先算好存成点数组每帧只做位置插值。class Vehicle { constructor(path, speed) { this.path path; // [{x,z}, ...] 预计算路径 this.speed speed; // 米/秒 this.dist 0; // 当前沿路径走过的距离 this.total this.calcTotal(); } calcTotal() { let sum 0; for (let i 1; i this.path.length; i) { sum Math.hypot(this.path[i].x - this.path[i-1].x, this.path[i].z - this.path[i-1].z); } return sum; } update(dt) { this.dist (this.dist this.speed * dt) % this.total; // 按 dist 在路径上定位返回 {x, z, angle} return this.locate(this.dist); } }dt是帧间隔用clock.getDelta()拿。用dt而不是固定步长能保证不同帧率下车辆速度一致。% this.total让车到终点后循环回起点。locate方法里做的是沿路径累加距离找到落在哪一段再线性插值。这套逻辑每帧只做加减乘除几千辆车也扛得住。注意路径点不要存成Vector3对象数组用普通对象或扁平数组减少 GC 压力。3.3 告警弹窗与场景联动事件总线比到处传引用干净告警弹窗、点位高亮、相机飞行这几个模块经常要互相触发。如果每个模块都持有其他模块的引用代码很快会变成一团乱麻。我一般会加一个极简事件总线模块之间只通过事件名通信。const bus { map: new Map(), on(name, fn) { if (!this.map.has(name)) this.map.set(name, []); this.map.get(name).push(fn); }, emit(name, payload) { (this.map.get(name) || []).forEach((fn) fn(payload)); }, }; // 告警模块只负责发事件 bus.emit(alert:focus, { id: sensor-001, lng: 116.41, lat: 39.91 }); // 相机模块监听并飞行 bus.on(alert:focus, ({ lng, lat }) { const { x, z } lngLatToWorld(lng, lat); flyTo(x, z); });事件总线的价值在于解耦。告警模块不需要知道相机怎么飞相机模块也不需要知道告警从哪来。代价是调试时事件流不直观建议在emit里加一行开发环境日志生产环境关掉。flyTo用Tween.js或自己写缓动都行关键是相机飞行期间要禁用用户轨道控制否则用户一拖拽就和动画打架画面会抖得很难看。4. 性能与视觉的取舍哪些特效该开哪些该砍4.1 后期处理Bloom 很香但别全屏开Bloom 泛光能让城市夜景瞬间高级但它本质是多趟全屏模糊分辨率越高越吃 GPU。我的做法是只在需要发光的图层上做 Bloom而不是全屏。Three.js 的EffectComposer配合SelectiveBloomPass可以做到但配置略繁琐。更省事的方案是给发光物体单独渲染到一张低分辨率 RenderTarget再叠加回主画面。const composer new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); const bloom new UnrealBloomPass( new THREE.Vector2(window.innerWidth / 2, window.innerHeight / 2), // 半分辨率 0.8, // strength 0.4, // radius 0.85 // threshold ); composer.addPass(bloom);UnrealBloomPass的第一个参数是分辨率直接传半分辨率能省一半以上开销视觉上几乎看不出差别。threshold控制多亮的像素才参与泛光调高它能让只有真正的高亮物体发光避免整个画面糊成一团。strength别超过 1否则夜景会过曝成一片白。这套参数不是固定的得根据你的场景亮度调建议做成调试面板实时改。4.2 视锥裁剪与 LOD看不见的别画看不清的别细画Three.js 默认会做视锥裁剪但前提是你的物体包围盒正确。合并后的建筑如果包围盒算错会出现“明明在视野里却被裁掉”或“背对着还在画”的情况。合并后记得调geometry.computeBoundingSphere()。LOD 则是对远处的建筑用简化模型近处用精细模型。智慧城市里建筑白模本身就不精细LOD 收益有限但路网和植被用 LOD 效果明显。优化手段适用对象预期收益代价几何合并建筑、路网draw call 降 90%失去单体变换InstancedMeshPOI、车辆draw call 降 95%拾取逻辑变复杂半分辨率 Bloom夜景特效GPU 开销降 50%轻微模糊LOD植被、远处建筑顶点数降 60%切换时可能跳变视锥裁剪全部静态物体视外物体零开销包围盒必须正确4.3 内存与显存贴图是隐形杀手很多人盯着 draw call 和顶点数却忽略了贴图。一张 4096×4096 的 PNG 解压后占 64MB 显存十张就是 640MB移动端直接崩。常见做法是贴图尺寸按实际显示大小定大屏上 2048 够用小图标 256 足够格式优先用压缩纹理但要注意浏览器兼容性同一张贴图被多个材质引用时Three.js 会复用别重复加载。const loader new THREE.TextureLoader(); loader.load(/assets/road.png, (tex) { tex.colorSpace THREE.SRGBColorSpace; // 颜色贴图必须设否则偏色 tex.anisotropy renderer.capabilities.getMaxAnisotropy(); // 斜视清晰度 tex.generateMipmaps true; tex.minFilter THREE.LinearMipmapLinearFilter; material.map tex; });colorSpace设错是经典翻车点颜色贴图不设SRGBColorSpace画面会发灰发暗很多人以为是光照问题调半天灯。anisotropy设成设备最大值能让地面贴图在斜视角下不糊代价是略微增加采样开销通常值得。generateMipmaps对需要缩小的贴图必须开否则远处会闪烁。5. 避坑与排查那些让你加班到凌晨的常见问题5.1 画面全黑但控制台无报错现象场景加载后一片黑没有报错相机位置也正常。原因通常是相机近远裁剪面设置不当或者模型坐标远超相机视野。解决先把相机拉到能看见原点的位置用AxesHelper确认坐标系方向检查camera.near和camera.far智慧城市场景far经常要设到几万米但near别设太小否则深度精度不够会闪烁。我一般设near 1、far 50000配合对数深度缓冲logarithmicDepthBuffer: true。5.2 模型加载后位置偏移或比例不对现象GLTF 模型加载进来后飘在半空或小得看不见。原因模型导出时的单位、原点、Y 轴朝向和你的场景约定不一致。解决加载后先打印box3看包围盒再决定缩放和平移。别直接改模型文件用代码包一层 Group 做归一化这样换模型时不用重新导出。const box new THREE.Box3().setFromObject(model); const size box.getSize(new THREE.Vector3()); const center box.getCenter(new THREE.Vector3()); model.position.sub(center); // 把模型中心移到原点 const scale 100 / Math.max(size.x, size.z); // 归一化到 100 米 model.scale.setScalar(scale);5.3 帧率随运行时间越来越低现象刚打开 60 帧跑十分钟掉到 20 帧。原因每帧都在创建新对象new Vector3、new ColorGC 频繁触发或者事件监听没解绑越积越多。解决把循环内的临时对象提到循环外复用组件销毁时用dispose()释放几何体、材质、贴图requestAnimationFrame的回调里别做 DOM 查询。5.4 射线拾取点不准现象点击建筑高亮的是旁边那栋。原因合并几何体后faceIndex对应的是合并后的面不是原始建筑或者相机有偏移没更新矩阵。解决合并前记录每栋楼的顶点索引范围拾取后按faceIndex反查相机移动后调camera.updateMatrixWorld()。InstancedMesh 拾取要用instanceId别用faceIndex。5.5 移动端直接白屏或闪退现象桌面正常手机打开白屏。原因显存超限、贴图太大、或者用了移动端不支持的扩展。解决移动端强制降级——贴图最大 1024、关 Bloom、建筑用更低模、renderer.setPixelRatio(Math.min(devicePixelRatio, 2))。别指望移动端跑和桌面一样的配置那不现实。6. 从能跑到好用一套可复用的调试与验收习惯源码实例最大的价值不是让你照抄而是让你看到“别人怎么组织”。我自己的习惯是拿到任何一套 Three.js 智慧城市代码先做三件事第一把渲染循环里的renderer.info打到面板上实时看 draw call、三角形数、内存占用这三个数字比帧率更能说明问题第二把相机控制、图层显隐、后期开关做成调试面板用lil-gui或自己写都行调参时不用改代码重编译第三给每个图层加一个“单独显示”按钮出问题时能快速定位是哪个图层拖垮了性能。验收阶段我会盯几个硬指标静态场景 draw call 控制在 100 以内动态对象用 InstancedMesh 后 draw call 不随数量增长1080p 下帧率稳定 50 以上移动端 720p 能跑 30 帧。达不到就回去砍特效、降贴图、合并几何。这套标准不是死的但有了它你就不会陷入“感觉有点卡但不知道卡在哪”的玄学状态。最后说个我踩过的坑早期做智慧城市我总想把所有数据都实时渲染觉得那样才“智慧”。后来发现大部分数据根本不需要每帧更新——建筑不变、路网不变、甚至车流也可以降频到 10Hz 更新人眼根本看不出差别。把更新频率降下来CPU 和 GPU 都松一口气。这个习惯帮我省下了大量优化时间也让我明白一个道理可视化项目的性能瓶颈往往不在渲染而在你舍不得放下的那些“实时”。希望帮到你。本文还有配套的精品资源点击获取