Cesium实现3DTiles分层分户抽屉效果:原理与完整代码 做智慧楼宇和房产展示这类Cesium项目十有八九会碰到一个需求把一栋楼按楼层、按户室拆开看。我第一次接这个需求时以为拿3DTiles模型做单体化高亮就完事了结果甲方直接说——能不能做成抽屉整层往上拉某一户水平抽出来。当时就懵了3DTiles加载起来容易可真要让模型像抽屉一样“抽开”涉及到的矩阵变换、数据组织、动画控制一样都绕不过去。这篇东西就是把完整的实现思路和代码整理出来。里面包含楼层整体抽开、户室独立拉出的实现写法包含Cesium场景初始化、3DTiles加载、DrawerManager抽屉控制类、点击交互和移动端适配要点最后还有我在实际项目里踩过的坑。适合正在做Cesium智慧楼宇、房产三维展示、BIM运维可视化或者单纯研究3DTiles模型动态控制的同学参考。代码是完整可跑的不过你得先准备一份拆好了的3DTiles数据或者直接用我后面给的Box演示逻辑也能看效果。1. 先想清楚“抽屉”到底要抽什么1.1 分层分户的业务逻辑在做功能之前一定要把业务方说的“分层分户”拆清楚。我遇到的需求一般分两种第一种是楼层级的抽屉。整栋楼按自然层拆开比如1层留在原地2层向上浮动一段距离3层再往上浮动一段距离用户能看到楼内每层的结构布置。这个常用于楼层设备展示、消防疏散演示、办公空间规划。第二种是户室级的抽屉。在当前楼层里点某一户这一户的模型往水平方向抽出来类似拉开柜子抽屉的效果。住宅楼盘的三维看房、分割出租的办公单元展示基本都是这种交互。这两种需求看起来不一样但落到Cesium里本质都是同一件事让一个独立的3DTiles模型相对它的原始位置沿某个方向平移一段距离而且整个过程要带平滑动画。你只要把“楼层”或“户室”都抽象成一个可独立控制的3DTiles体块剩下的就是矩阵计算和动画插值的问题。1.2 抽屉效果的技术本质Cesium里的任何3DTiles模型最终都是通过一个4x4矩阵来决定它放在世界坐标系哪个位置的这个矩阵就是tileset.modelMatrix。模型显示的位置 modelMatrix × 模型自身的坐标点。抽屉效果要做的事情说白了就是把modelMatrix改变一下。假设模型原始矩阵是M你想让它往上移动3米那就构造一个平移矩阵T然后计算新的矩阵如果想沿世界坐标系的Z轴往上移动新矩阵 T × M如果想沿模型自己的本地坐标轴移动新矩阵 M × T这个区别特别关键后面会专门讲。明白这一点后抽屉效果就不再是玄学而是“记录原始矩阵 → 按时间插值计算平移量 → 更新modelMatrix”这样一个标准的动画循环。1.3 技术选型为什么拆tileset而不是改节点有人会问能不能只加载一个3DTiles然后在代码里把模型节点拆开做抽屉这个思路理论上行得通3DTiles里的b3dm本身是glTF结构Cesium的ModelExperimental也暴露了一些节点控制能力但实际做的时候很痛苦。一是Cesium的节点遍历API在不同版本间变动较大今天能用的一段代码升级一个版本可能就失效。二是运行时修改glTF节点的matrix容易和3DTiles自己的调度系统打架出现包围盒不更新、裁剪异常、画面闪烁之类的怪问题。三是如果模型在数据生产阶段就没有把楼层、户室建成独立节点前端再怎么遍历也拆不出你想要的分组。所以我在实际项目里默认采用“数据侧先拆好前端侧分别控制”的方案每个楼层是一个单独3DTiles每个需要做抽屉的户室也是一个单独3DTiles加载后通过modelMatrix控制各自的平移。这样做矩阵逻辑最简单动画控制最稳排查问题也直观性能上只要控制好体块数量完全扛得住。后面代码部分我也是按这个方案写的。2. 数据准备分层分户的3DTiles从哪来2.1 最省事的建模和转换流程很多同学卡在第一步我没有分层分户的3DTiles数据。这个问题的答案取决于你的原始数据是什么格式。如果是BIM模型比如Revit导出的FBX或者SketchUp的SKP最省事的方式是在建模软件里就把楼层、户室分开导出一个楼层导出一个glTF/glb文件一个户室导出一个glb文件。导出后再用工具统一转成3DTiles。这样你在前端就能按文件夹结构去加载。如果是倾斜摄影模型比如航拍生成的OSGB那就需要先做单体化切割把一栋楼从倾斜模型里切出来再按楼层或户室切分。这个步骤最好用专门的模型处理工具来做手工在前端切是不现实的。如果是CAD图纸或GIS数据比如SHP面数据想要带高度的白模或简单体块可以用工具直接拉伸成三维体块再转3DTiles。这个过程我后面会列工具链。素材层面有一个很重要的规范转换前一定要把每个楼层、每户的原点对齐到同一个基准点。比如1楼的原点在(0,0,0)2楼原点是(0,0,3.5)3楼原点是(0,0,7)。如果你每个模型导出时原点都随意摆前端加载时就要靠矩阵去校正位置会非常痛苦。2.2 工具链与格式流转我再梳理一下从原始数据到3DTiles的常见流转路线方便你按自己的情况选。FBX / glTF / glb 转 3DTilesCesium ion是最省心的拖进去自动转本地工具推荐CesiumLab支持批量转换而且可以设置LOD和坐标校正。23Tiles也可以但更偏命令行。SHP 转 3DTiles如果只有带高度的建筑轮廓面可以用CesiumLab的矢量转三维模型功能或者用py3dtiles这类开源库写脚本处理。OSGB倾斜摄影 转 3DTilesCesiumLab和ContextCapture都能干这个活转完记得检查坐标系统和模型细节层次。城市级数据如果你手上的数据是别的格式比如MVT这种矢量瓦片想变成3DTiles还是建议先转成GeoJSON或SHP再走矢量转3DTiles的流程。Cesium本身也能直接加载MVT做底图或矢量图层但那是另一条技术路线跟分层分户的抽屉逻辑不冲突。转换过程中要特别注意坐标单位。3DTiles要求的单位通常是米如果你的模型是从别的软件导出的单位是厘米或英寸转出来之后位置和缩放都会乱。养成好习惯每次加载完tileset第一件事就是检查它是不是在正确的位置、正确的尺度上。2.3 单模型也想做抽屉备选方案如果你的数据确实无法拆分只能拿到一个完整的3DTiles也有办法做“伪抽屉”。最常见的做法是用Cesium的裁剪平面clippingPlanes配合模型复制。说白了就是把原始模型裁掉一部分再把被裁掉的部分用另一个tileset或primitive复制出来并向上平移看起来就像楼层被抽开了。这个方案适合做楼层剖切展示但做精细的户室抽屉会很别扭因为复制出来的部分和原来的模型在光照、阴影、缝边这些细节上容易对不齐。还有一种思路是走glTF节点控制。3DTiles内的b3dm模型本质是glTF如果你能拿到模型内部节点的名字比如建模时把每户设成了独立的节点可以在Cesium的ModelExperimental里按节点名去取node然后修改node.matrix。但我再次提醒这套API属于实验性质升级版本前一定要做回归测试。生产中我只会把这种方案当作“救急手段”不会作为默认方案。所以还是那句话能做数据拆分尽量在数据阶段解决前端的活就越简单越稳定。3. 完整可运行代码抽屉管理器与交互控制这一节直接给能跑的代码。我用一个DrawerManager类统一管理所有抽屉体块的注册、展开、收回和动画配合一个演示页面把这个功能做成通用模块。你只要按自己的数据路径替换一下urls配置就能在项目里用。3.1 页面骨架与Cesium场景初始化先搭一个页面。Cesium我用CDN方式引入版本以1.120为例。注意两点一是Cesium访问在线底图前需要配置Ion的token二是如果你部署在内网环境默认的在线图源可能加载不出建议准备离线底图或者换成公司自己的瓦片服务。!DOCTYPE html html langzh-CN head meta charsetutf-8 / meta nameviewport contentwidthdevice-width, initial-scale1, maximum-scale1, user-scalableno / title3DTiles 分层分户抽屉效果/title link hrefhttps://cesium.com/downloads/cesiumjs/releases/1.120.0/Build/Cesium/Widgets/widgets.css relstylesheet / style html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; overflow: hidden; font-family: Microsoft YaHei, sans-serif; } #toolbar { position: absolute; top: 12px; left: 12px; z-index: 10; padding: 10px 12px; background: rgba(0, 0, 0, 0.72); border-radius: 8px; color: #fff; display: flex; flex-direction: column; gap: 6px; font-size: 14px; max-height: 80vh; overflow-y: auto; backdrop-filter: blur(6px); } #toolbar button { padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer; background: #2c6fbb; color: #fff; font-size: 14px; } #toolbar button:hover { background: #3a80cf; } .group-title { font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.3); padding-bottom: 4px; margin-bottom: 4px; } /style /head body div idcesiumContainer/div div idtoolbar div classgroup-title楼层抽屉/div button onclickexpandAllLevels()展开全部楼层/button button onclickresetAll()全部收回/button div classgroup-title户室抽屉/div button onclickopenRoom(room-1-101)拉出1F-101/button button onclickopenRoom(room-1-102)拉出1F-102/button button onclickopenRoom(room-2-201)拉出2F-201/button /div script srchttps://cesium.com/downloads/cesiumjs/releases/1.120.0/Build/Cesium/Cesium.js/script script // 你的Cesium Ion token没有就去官网申请一个 Cesium.Ion.defaultAccessToken 你的token; const viewer new Cesium.Viewer(cesiumContainer, { animation: false, baseLayerPicker: false, geocoder: false, timeline: false, sceneModePicker: false, navigationHelpButton: false, infoBox: false, selectionIndicator: false, shouldAnimate: true, requestRenderMode: false }); // 去掉默认的双击定位避免误触 viewer.cesiumWidget.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); // 3D Tiles相关参数 const tilesetOptions { maximumScreenSpaceError: 16, dynamicScreenSpaceError: true, dynamicScreenSpaceErrorDensity: 0.8, cullWithChildrenBounds: true }; /script /body /html这里我把UI按钮直接放页面上方便你测试。真正做项目时可以把按钮事件替换成你自己的菜单组件。viewer的默认控件我关掉了一堆留一个清爽的三维场景。3.2 DrawerManager抽屉控制核心类DrawerManager是整个方案的大脑。它的职责有三块注册抽屉体块、播放展开收回动画、按帧更新modelMatrix。关键是它一定要记住每个体块的baseMatrix也就是原始矩阵。动画过程中只更新“偏移量”再把这个偏移矩阵乘回baseMatrix。凡是动画之后出现模型乱飞、回弹、越抽越偏的十有八九是每次都在当前矩阵基础上继续累加没有回到baseMatrix重新计算。class DrawerManager { constructor(viewer) { this.viewer viewer; this.items new Map(); } // key: 抽屉唯一标识 // tileset: Cesium3DTileset实例 // baseMatrix: 抽屉原始矩阵必须记录正确 register(key, tileset, baseMatrix) { this.items.set(key, { tileset, baseMatrix: Cesium.Matrix4.clone(baseMatrix), currentOffset: Cesium.Cartesian3.ZERO, targetOffset: Cesium.Cartesian3.ZERO, animating: false }); // 给tileset打上标记方便拾取时反查key tileset.drawerKey key; } // 展开向某个方向移动 distance 米 // direction: Cartesian3比如向上 new Cesium.Cartesian3(0, 0, 1) // useLocal: true 表示沿模型本地坐标方向移动false 表示沿世界坐标方向移动 open(key, direction, distance, duration 1.2) { const item this.items.get(key); if (!item) return; const targetOffset Cesium.Cartesian3.multiplyByScalar( direction, distance, new Cesium.Cartesian3() ); item.targetOffset targetOffset; item.animating true; item._useLocal Boolean(useLocal); } // 收回偏移量归零 close(key, duration 1.2) { const item this.items.get(key); if (!item) return; item.targetOffset Cesium.Cartesian3.ZERO; item.animating true; } // 切换抽屉状态正在外面就收回在原始位置就展开 toggle(key, direction, distance, duration) { const item this.items.get(key); if (!item) return; const isOut Cesium.Cartesian3.distance(item.currentOffset, Cesium.Cartesian3.ZERO) 0.01; if (isOut) { this.close(key, duration); } else { this.open(key, direction, distance, duration); } } // 每帧调用做平滑插值 update() { for (const item of this.items.values()) { if (!item.animating) continue; const target item.targetOffset; const current item.currentOffset; // 简单缓动每帧向目标方向移动约20%的剩余距离 // 也可以在外部传入更精细的时间轴动画这里用指数衰减手感还可以 const ratio 0.18; let x Cesium.Math.lerp(current.x, target.x, ratio); let y Cesium.Math.lerp(current.y, target.y, ratio); let z Cesium.Math.lerp(current.z, target.z, ratio); // 距离足够近时直接收紧到目标值 if (Math.abs(target.x - x) 0.001 Math.abs(target.y - y) 0.001 Math.abs(target.z - z) 0.001) { x target.x; y target.y; z target.z; item.animating false; } item.currentOffset new Cesium.Cartesian3(x, y, z); this._applyOffset(item); } } _applyOffset(item) { const offsetMatrix Cesium.Matrix4.fromTranslation( item.currentOffset, new Cesium.Matrix4() ); if (item._useLocal) { // 沿模型本地坐标移动右乘 item.tileset.modelMatrix Cesium.Matrix4.multiply( item.baseMatrix, offsetMatrix, new Cesium.Matrix4() ); } else { // 沿世界坐标移动左乘 item.tileset.modelMatrix Cesium.Matrix4.multiply( offsetMatrix, item.baseMatrix, new Cesium.Matrix4() ); } } } // 在Cesium每帧渲染前更新抽屉位置 const drawerManager new DrawerManager(viewer); viewer.scene.postUpdate.addEventListener(() { drawerManager.update(); });这段代码里的update用的是指数衰减插值虽然没有严格的时间曲线但视觉效果平滑代码也最简单。如果你希望展开和收回速度完全可控可以把这里换成基于clock的时间插值原理一样就是把ratio换成根据时间计算出的0到1的进度值。3.3 配置化加载楼层与户室数据加载我建议全部配置化。把楼层、户室的路径和原始位置写在一个数组里启动时循环加载。这样后期新增一栋楼、调整一层高度只改配置就行不用动逻辑代码。下面演示一栋三层的楼每层包含若干户室。小体块户室我单独拆出来作为可抽屉体块。// 配置每层的tileset地址和其在世界坐标中的基准位置 // 注意这里的位置是你模型在数据生产时约定的坐标单位是米 const levelConfigs [ { key: level1, name: 1F, url: ./data/floor1/tileset.json, offset: [0, 0, 0] // 第1层基准位置 }, { key: level2, name: 2F, url: ./data/floor2/tileset.json, offset: [0, 0, 3.5] // 第2层基准位置层高3.5米 }, { key: level3, name: 3F, url: ./data/floor3/tileset.json, offset: [0, 0, 7.0] // 第3层基准位置 } ]; // 配置需要做抽屉的户室 // 每个户室是独立tilesetoffset是它的原始位置 const roomConfigs [ { key: room-1-101, name: 1F-101, url: ./data/rooms/101/tileset.json, offset: [2.0, 2.0, 0] }, { key: room-1-102, name: 1F-102, url: ./data/rooms/102/tileset.json, offset: [6.0, 2.0, 0] }, { key: room-2-201, name: 2F-201, url: ./data/rooms/201/tileset.json, offset: [2.0, 2.0, 3.5] } ]; async function loadTileset(url, position) { const tileset await Cesium.C3DTileset.fromUrl(url, tilesetOptions); const positionMatrix Cesium.Matrix4.fromTranslation( Cesium.Cartesian3.fromElements(position[0], position[1], position[2]) ); tileset.modelMatrix positionMatrix; viewer.scene.primitives.add(tileset); return tileset; } async function initDrawers() { // 先加载楼层作为整体背景 for (const cfg of levelConfigs) { const tileset await loadTileset(cfg.url, cfg.offset); drawerManager.register(cfg.key, tileset, tileset.modelMatrix.clone()); } // 再加载户室 for (const cfg of roomConfigs) { const tileset await loadTileset(cfg.url, cfg.offset); drawerManager.register(cfg.key, tileset, tileset.modelMatrix.clone()); } // 初始化相机视角 const lastLevel levelConfigs[levelConfigs.length - 1]; const target Cesium.Cartesian3.fromElements( lastLevel.offset[0], lastLevel.offset[1], lastLevel.offset[2] ); viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(116.391, 39.907, 400), orientation: { heading: 0, pitch: Cesium.Math.toRadians(-30), roll: 0 }, duration: 1.5 }); } initDrawers();注意这里用了viewer.camera.flyTo目的地我写的是北京某个经纬度实际项目中你要根据自己模型的位置来设。如果你是离线数据或局部坐标系数据可以用Cesium.Transforms.eastNorthUpToFixedFrame把经纬度转成世界坐标。3.4 交互控制按钮、点击、相机联动下面是按钮事件以及点击拾取户室的事件。这样用户既可以通过UI菜单打开抽屉也可以直接在场景里点击某户的模型点击后这一户自动水平拉出。// 点击楼层体块或户室体块命中后触发抽屉 const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((movement) { const picked viewer.scene.pick(movement.position); if (!picked || !picked.primitive) return; const tileset picked.primitive; if (!tileset.drawerKey) return; // room抽屉沿世界X方向拉出2.5米level抽屉沿世界Z方向拉出3米 if (tileset.drawerKey.startsWith(room-)) { drawerManager.toggle(tileset.drawerKey, new Cesium.Cartesian3(1, 0, 0), 2.5, 1.0); } else if (tileset.drawerKey.startsWith(level)) { drawerManager.toggle(tileset.drawerKey, new Cesium.Cartesian3(0, 0, 1), 3.0, 1.0); } }, Cesium.ScreenSpaceEventType.LEFT_CLICK); // 展开全部楼层从顶层开始逐层拉开 function expandAllLevels() { const gap 2.0; // 每层额外拉开的间距单位米 levelConfigs.forEach((cfg, index) { // 第index层额外偏移 index * gap const extraZ index * gap; const tileset drawerManager.items.get(cfg.key).tileset; drawerManager.open(cfg.key, new Cesium.Cartesian3(0, 0, 1), extraZ, 1.0); }); } // 拉出指定户室 function openRoom(roomKey) { drawerManager.open(roomKey, new Cesium.Cartesian3(1, 0, 0), 2.5, 1.0); } // 全部收回 function resetAll() { for (const key of drawerManager.items.keys()) { drawerManager.close(key, 0.8); } }点击拾取这里有一个隐含问题如果你每个户室本身就是独立tileset那点击拾取返回的primitive就是这个tileset判断drawerKey就能知道点了哪个抽屉。但如果你某个户室被建模融合进了整层模型里拾取结果可能拿不到户室的独立tileset这种情况就需要做单体化数据或者用3DTiles的feature属性去反查后面第4.4节我再细说。3.5 没有数据也能跑用Box演示抽屉逻辑如果你手头暂时没有拆好的3DTiles也不想卡在数据制作上可以用下面这段代码直接生成几个Box几何体来验证抽屉逻辑。原理和tileset一模一样因为Entity在Cesium内部也有position和orientation但为了方便演示我这里直接创建Primitive几何体再包一层tileset的壳不太现实所以就用Entity模拟也是可以的。不过严格说Entity的变换不是modelMatrix。我建议这样测直接用Cesium.BoxGeometry创建几何体包成一个Cesium.Primitive然后把它塞进drawerManager里注册。但是DrawerManager依赖的是tileset为了通用性我在项目里会把DrawerManager的register改成不依赖tileset类型只要求传入一个带modelMatrix属性的对象。你如果想拿Box测试可以构造一个伪装对象。// 仅供快速验证创建一个带modelMatrix的几何体对象 function createBoxPrimitive(name, position, size) { const modelMatrix Cesium.Matrix4.fromTranslation(position); const geometry Cesium.BoxGeometry.fromDimensions({ vertexFormat: Cesium.VertexFormat.POSITION_AND_COLOR, dimensions: new Cesium.Cartesian3(size.x, size.y, size.z) }); const primitive new Cesium.Primitive({ geometryInstances: new Cesium.GeometryInstance({ geometry, modelMatrix, color: Cesium.Color.fromRandom({ alpha: 0.8 }) }), asynchronous: false }); primitive.modelMatrix modelMatrix; primitive.drawerKey name; viewer.scene.primitives.add(primitive); return primitive; } // 测试代码 const box1 createBoxPrimitive(room-test-1, Cesium.Cartesian3.fromDegrees(116.391, 39.907, 50), { x: 4, y: 4, z: 3 }); drawerManager.register(room-test-1, box1, box1.modelMatrix.clone());注意我这里的Box用的fromDegrees直接指定了经纬度坐标而楼层配置里用的offset是相对某个原点的局部坐标。真正常用做法是所有模型统一用一个真实世界的坐标原点比如楼栋的经纬度然后各层、各户的offset都写成相对于这个原点的局部偏移。这样才能保证整个楼栋各部分拼在一起。4. 实战中的坑与优化建议4.1 矩阵顺序错了抽屉直接飞出地球这是抽屉效果里最经典的坑。左乘和右乘的区别我再啰嗦一遍。假设你要让一个倾斜的楼顶模型沿世界坐标系的Z轴往上抬也就是让模型垂直上升用左乘T × M。因为T作用在世界坐标系所以不管模型本身怎么旋转它都严格沿世界Z轴移动。如果模型是一个旋转过的户室你想让它从楼里沿着户型图上的“面宽方向”抽出来也就是沿模型自己本地坐标的X方向这时候用右乘M × T。效果是该户室在保持自身方向的同时沿自己的本地X轴滑出来。我见过很多同学第一次写这里方向反了模型当场弹到地底下或者往东南西北乱窜。原因就是模型baseMatrix里带着旋转量平移矩阵乘反了后世界坐标和本地坐标完全错位。写代码时建议在注释里标清楚避免下次改业务时搞混。4.2 动画抖动、回弹和累计漂移抽屉动画最常见的三个诡异现象我都遇到并排查过。第一个是模型在抽出去之后轻微抖动。原因往往是你没有把baseMatrix固定住而是拿当前modelMatrix继续乘新的偏移矩阵。模型每次变动都以当前状态作为基准不断叠加就会累计出不可控的偏移。上面的代码里我每次都从baseMatrix重新计算就是为了规避这个问题。第二个是动画结束时出现“咔哒”一下的回弹。这个是因为每帧插值速度固定到目标值时直接刹停人和视觉系统感受到一个急停的瞬间。解决办法要么用更平滑的缓动函数要么判断距离足够近后不要瞬间置为目标值而是停在当前帧计算值再把animating置为false。我上面代码用的是后者基本无感。第三个是抽屉抽出去之后相机如果执行flyTo(tileset)可能突然飞到场景中心或者拉到一个奇怪的距离。因为tileset的包围盒在移动后会重新计算但有时候相机动画在移动过程中计算了旧的包围盒导致目标点漂移。解决办法是抽屉动画停止后再执行viewer.flyTo(tileset)不要用tileset.boundingSphere在动画前快照来做路径点。4.3 性能与移动端触屏适配分层分户抽屉效果性能问题主要出在“户室数量”。一栋楼如果每户都是独立tileset那户外级可能就几十个甚至上百个tileset。每个tileset都有自己的包围盒、请求队列和渲染批次Cesium的管理开销会明显增大。我的优化经验是楼层整体保留为一个大tileset不要拆太碎只有需要独立抽屉的户室才单独拆出来。户室抽屉做完动画后如果长期停留在外露状态可以考虑把底层的整体模型设为半透明或隐藏减少渲染量。如果你有很多户室还可以对抽屉体块做LOD和显隐控制远离相机时直接tileset.show false靠近时再显示。移动端适配这块容易被忽略。首先页面头部的viewport一定要写全否则手机上看会是桌面缩放效果。其次Cesium默认的双指旋转和地图缩放有时会打架建议在ScreenSpaceEventHandler里屏蔽或自定义手势。还有在线底图在移动端弱网环境下经常加载不出来所以我在项目里会用公司内部的离线瓦片服务做底图而不是依赖Cesium Ion默认的在线影像。如果发现Cesium Ion相关图片无法访问多半是网络或token问题优先排查这两处不要一上来就怀疑代码。4.4 单体化、标签跟随和更多扩展分层分户抽屉做完后通常还要搭配一些辅助功能才像一个完整的交付物。单体化是最常见的。如果你只有一栋楼的整体3DTiles没有按户室拆分但要在前端做“点哪户高亮哪户”的效果可以用Cesium3DTileset的style参数按属性区分或者用pickFeature拾取tile里的feature再设置feature的show或color。要注意的是这个能力要求3DTiles数据在生成时带有属性信息比如每个feature有floor、roomNo字段。如果你的数据是纯白模没有任何属性单体化是做不出来的只能回到拆分体块那一套方案。标签跟随也有点讲究。抽屉拉出来的户室需要有一个楼栋标签实时跟着模型走。最简单做法是每帧在postUpdate里读取该tileset的boundingSphere.center然后更新LabelCollection对应label的position。我这里没有写进主代码因为那会让示例变得很重但原理就是上面这一句。再说几个很多人问我能不能一并做的点。Cesium绘制矩形、鹰眼、热力图、雷达扫描这些本质都是二维或三维的独立图层功能和抽屉效果互不干扰可以叠加到同一个viewer上。MVT数据如果要做成三维分层分户体块建议先转成GeoJSON再建模而不是试图让3DTiles去识别MVT的图层字段。如果你用的是Cesium for Unity或UE5抽屉效果的思路依然成立因为底层都是矩阵和模型实例的控制只不过API名字换了一套遇到不显示版权之类的问题通常是组件初始化或认证配置的问题。结尾先空着我再补一段个人体会。我自己的经验是抽屉效果看着炫真正决定项目成败的往往不是动画技术而是数据规范。如果你能在数据生产阶段把楼层、户室拆得清清楚楚原点对齐、命名规范、属性齐整那前端代码可以做得非常简洁稳定。反过来数据一团乱麻前端再怎么写矩阵变换也救不回来。最后再分享一个小技巧在做抽屉动画时不要把动画逻辑散落到各个页面的按钮事件里像我上面那样封装成一个DrawerManager类所有抽屉统一注册、统一更新、统一收回。后期无论是增加新楼层、新户室还是换成真实的BIM模型成本都会低很多。