前阵子接了个智慧楼宇的项目甲方提了一个很具体的要求点一下楼层整栋楼要能像抽屉一样把那一层拉出来其他楼层自动收起来。这个需求在智慧园区、房产展示、物业管理里非常常见说白了就是给 3DTiles 模型做分层分户展示但“抽屉效果”这种交互形态比单纯切楼层、透明楼栋要直观得多。做的时候踩了不少坑网上关于“Cesium 分层分户抽屉效果”的完整案例也不多大部分帖子只讲了裁剪面或者透明度切换根本做不出“拉抽屉”的体感。我把自己跑通的这套方案整理出来从数据准备、方案选型到核心代码、常见问题一次性讲透。不管你用的是 Cesium 1.9x 还是新版 1.11x这整套思路都能直接用上。1. 分层分户抽屉效果到底解决什么问题1.1 业务场景谁需要这个效果凡是跟建筑、楼层、房间打交道的三维可视化系统几乎都会遇到分层分户的需求。比如智慧楼宇系统里你选中第 8 层就要看清这一层的房号、走廊、消防通道房产售楼系统里客户点某个户型就要把户内结构单独拉出来看物业管理系统里报修工单定位到具体房间也要有从楼栋到户内的视角切换。传统做法一般是把楼栋模型整体加载进来点击楼层时通过透明度混合显示或者用 Cesium 的 clippingPlanes 把楼体切一刀。这两种方式在视觉上都像是“拆楼”用户很难直观建立“这一层独立存在”的空间感。抽屉效果的好处是把“查看某一层”变成了一个物理动作——楼层像抽屉一样被拉出来周围楼层收拢视口中心正好落在目标层交互逻辑非常直白几乎不需要用户学习成本。我在这个项目里遇到的实际诉求是楼是一栋 28 层的高层办公楼地下一层到地上三层是商业裙房四层以上是标准层。甲方要求点击任意楼层能快速看清该层的房间划分、租户信息和设备点位同时其他楼层不能完全消失要给用户保留“楼还在”的上下文感。抽屉效果正好满足这一点拉开某一层其他楼层只是压缩位移没有从场景里移除。1.2 抽屉效果的产品形态与交互逻辑抽屉效果的产品形态可以拆成三个层次来看。第一层是“收拢态”整栋楼完整显示楼层之间紧密排列外观就是一栋正常的建筑。这个时候用户可以点楼栋、点楼层、点房间拾取到对应属性。第二层是“拉开态”当用户点击某个楼层后目标楼层在竖直方向上被抬高或者保持原位其他楼层按楼层索引向下/向上压缩楼层之间出现明显的间隙。间隙大小决定了“抽屉感”强不强建议拉开距离控制在单层高度的一半到一倍之间太小看不清太大视线要来回扫。第三层是“回落态”用户关闭抽屉效果后所有楼层恢复到原始位置楼栋回到收拢态。回落过程最好带缓动动画直接从拉开态硬切回收拢态会很突兀观感很差。除了这三个状态还要考虑相机配合。拉开抽屉后如果不调整相机视角目标楼层可能在天上或者被其他楼层挡住。所以我在实现时做了相机联动抽屉拉开后相机自动飞到一个能平视目标楼层的角度让目标楼层处于画面前三分之一位置户内细节一目了然。2. 技术方案选型为什么不能靠裁剪一刀切2.1 典型方案的横向对比在确定用“模型节点位移 动画”方案之前我把常见的四类方案都试了一遍。clippingPlanes 裁剪面方案适合做剖面展示能沿着一个平面把楼体切开看到内部结构。但它本质上是从视觉上“削掉”一部分几何体做不成位移效果。你可以用它模拟用刀切楼但做不到“把某一层抽出来”。polygonOffset 方案是用来解决模型重叠闪铄的和分层分户没关系。透明度方案在可视化上看效果最方便把非目标楼层调成半透明但楼层之间会互相透视多层透明叠加后视觉很乱尤其是建筑内部有隔墙、家具时画面上会出现大量重叠轮廓没法看清目标楼层。我最终采用的是“节点变换矩阵方案”把每个楼层作为独立的 3DTiles 节点保存每一层的原始 transform抽屉动画时通过 Cesium.Matrix4 动态修改楼层节点的矩阵产生位移。这样做的优势是楼层之间是真正的空间位移视觉自然保留了所有楼层在场景中的存在感后续还能给非目标楼层加半透明、压暗等效果动画过程用 requestAnimationFrame 驱动可以挂任何缓动曲线体感很好不触发模型重新加载性能损耗远低于卸载/加载方案。这四类方案的对比我整理成了一张表方案能否做出位移感实现复杂度性能表现适合场景clippingPlanes 裁剪否中需要实时裁剪GPU 开销较大剖面图、剖切观察透明度混合否低透明物体渲染排序开销大简单楼层概览卸载/重加载楼层弱高频繁 IO卡顿明显低楼层、模型小的场景节点变换矩阵强中仅上传新的变换矩阵开销极低任意分层分户抽屉效果2.2 数据准备3DTiles 如何做到“可分户”方案虽然定下来了但能不能真正实现抽屉取决于 3DTiles 数据的节点结构和属性信息。这一点比代码本身更关键。先说节点结构。如果你的模型是设计院发来的 Revit 模型导出成 fbx 或者 glTF 之后楼层对象往往还在——比如根节点下面有 FL00R1、FL00R2 这样的子节点每个节点里挂着一整层的墙体、门窗、家具构件。对这种数据用 Cesium 加载后是可以直接在节点树里找到楼层根节点的修改这个节点下的 transform 就能做整体位移非常优雅。但如果你拿到的是 CesiumLab 一键转出来的 b3dm且原始模型没有保留对象分组那 tileset 的节点树很可能只有一个根节点整个楼是一个整体无法按楼层拆开。遇到这种情况要么回到建模软件重新导出把每层单独命名要么在转换工具里配置“按楼层分组”的选项要么用方案 A把每一层分别导出成独立的 3DTiles 文件运行时用多个 tileset 管理。CesiumLab 里做 shp 转 3DTiles 的时候属性信息是可以带过来的比如每栋楼的楼栋号、楼层号、户号、面积。这些属性会进入 b3dm 的 batchTable。Cesium 拾取到 3DTilesFeature 后可以通过 getProperty 方法直接读取。所以做分层分户前先把数据里的楼层字段统一好建议至少包含这三个字段字段名含义示例building_id楼栋唯一标识B-01floor楼层号8F、F08、8unit户/房间号801、802楼层字段值建议统一格式不要混用。我在项目里吃过亏1F、01、1 混在一套数据里代码里解析楼层排序排了半天最后只能用正则把 A 区/ B 区硬拆开。后面我会在问题列表里详细说这个坑。2.3 环境与工具链我做这个项目用的 Cesium 版本是 1.108功能上 1.9x 之后的版本都能跑通这套代码。新版 Cesium 把 Cesium3DTileset.fromUrl 改成异步方法后加载写法稍微变了一下但核心的模型矩阵、节点遍历、拾取 API 没变。如果你的项目还在用老版本参考代码时注意把 fromUrl 换成旧写法就行。工具链方面我用到的有CesiumLab模型转换、shp 转 3DTiles 的主力工具支持自定义属性保留Blender glTF Exporter用来处理没有楼层分组的模型重新导出时给每层建空对象分组Cesium ion用于快速发布 3DTiles 服务方便调试前端构建工具我用的是 Vite TypeScript但本文代码用原生 JS 写确保你拷过去就能跑。数据层面如果拿到的原始数据是 Revit 导出 fbxCesiumLab 可以直接转 3DTiles。如果拿到的是一堆 shp 面数据也可以用 CesiumLab 或 FME 转成带属性的 3DTiles。后面讲的代码依赖的核心是“节点名或属性里有楼层号”数据满足这一点抽屉效果就跑得起来。3. 核心实现抽屉效果的完整代码与原理3.1 加载 3DTiles 与节点遍历先上一个最基础的 3DTiles 加载代码。这里我把 Cesium3DTileset.fromUrl 放在 async 函数里返回值拿到后在加载完成回调里做楼层分组。const viewer new Cesium.Viewer(cesiumContainer, { animation: false, baseLayerPicker: false, geocoder: false, timeline: false, sceneMode: Cesium.SceneMode.SCENE3D, infoBox: false }); const tileset await Cesium.Cesium3DTileset.fromUrl(/data/building/tileset.json, { maximumScreenSpaceError: 8, cullRequestsWhileMoving: true, preloadWhenHidden: true }); viewer.scene.primitives.add(tileset); await viewer.zoomTo(tileset, new Cesium.HeadingPitchRange(0, -0.6, 120));这里几个参数说一下避免你直接用默认值踩坑。maximumScreenSpaceError 控制瓦片细分程度数值越小加载越精细但请求量和渲染负担越大。对一栋楼来说8 是一个比较平衡的值。cullRequestsWhileMoving 是为了旋转视角时不会疯狂加载瓦片开启后移动过程中暂停新请求交互会更跟手。加载完成之后下一步就是要拿到模型节点树。Cesium 的 Cesium3DTileset 继承自 Cesium3DTileContent一个 tileset 下面有 rootroot 下面有 children。但注意这个 children 是 3D Tiles 的瓦片树不一定和模型的楼层层级一致。如果模型转换时保留了楼层分组那么你会在某个层级的瓦片节点上看到以楼层命名的名称。我这里封装了一个遍历节点的方法方便你找到所有包含“楼层信息”的节点function traverse3DTileset(node, callback) { callback(node); const children node.children; if (children) { for (let i 0; i children.length; i) { traverse3DTileset(children[i], callback); } } } traverse3DTileset(tileset.root, (node) { // 拿到节点名打印出来看结构 if (node.content node.content.featuresLength 0) { const feature node.content.getFeature(0); const floor feature.getProperty(floor); console.log(tile name:, node._header?.content?.uri, floor:, floor); } });这段代码里我用 node.content.getFeature(0) 去探测第一个 feature 有没有 floor 属性。实际的楼层分组更可靠的方式是看瓦片名。Cesium 内部的 tile._header 结构不对外保证稳定所以生产环境我建议你通过 batchTable 属性来分组而不是依赖节点名。后面小节会讲。3.2 从节点与属性中提取楼层分组拿到模型之后需要把所有 feature 按楼层号归入一个 Map。这一步是抽屉效果的数据底座。我直接遍历 tileset 里所有瓦片的所有 feature读取 floor 属性按楼层分组function extractFloorMap(tileset) { const floorMap new Map(); // key: floor, value: Cesium3DTileFeature[] traverse3DTileset(tileset.root, (node) { const content node.content; if (!content) return; const featuresLength content.featuresLength; for (let i 0; i featuresLength; i) { const feature content.getFeature(i); let floorValue feature.getProperty(floor); if (floorValue undefined) { // 有些数据用 FLOOR、楼层 等字段这里做兜底 floorValue feature.getProperty(FLOOR) || feature.getProperty(楼层); } if (floorValue undefined || floorValue null) continue; const floorKey String(floorValue).trim(); if (!floorMap.has(floorKey)) { floorMap.set(floorKey, []); } floorMap.get(floorKey).push(feature); } }); return floorMap; }这里有一个性能上的注意点如果模型体量很大比如整栋楼有几十万个 feature遍历所有 feature 会很慢甚至卡死。我实测在 28 层、单层 2000 多个构件的建筑上全量遍历耗时大概在 1~2 秒还在可接受范围。如果你面对的是大型园区多栋楼建议提前服务端算好“楼层-节点”映射表前端直接拉 JSON不现场遍历。楼层分组拿到之后抽屉效果的核心问题就变成了我要把某几层的 feature 在竖直方向做位移。Cesium 的 feature 本身不支持独立 transform所以这里必须回到“模型节点变换”。也就是说你最好在数据阶段就把每个楼层包成一个单独的 node。如果数据已经是“一层一 node”的结构那我更推荐用 node 级 transform 方案。在 Cesium 中遍历 3DTiles 节点通过 tile.transform 读取当前节点相对父节点的变换矩阵然后做 Matrix4 乘法实现位移。这个方案的性能比 feature 级操作好得多因为变换矩阵只需要上传若干个而不是逐个 feature 去改。3.3 矩阵变换与抽屉动画要理解这段代码先要明白 Cesium 里 3DTiles 的变换机制。一个瓦片节点在场景里的世界矩阵是tileset.modelMatrix * tile.computedTransform * 当前节点的本地变换我们做抽屉位移只需要修改当前节点的 transform把它叠加上一个竖直方向的偏移量。封装一个函数输入节点原始矩阵和偏移高度返回叠加后的新矩阵function createFloorTransform(originMatrix, deltaHeight) { const translation Cesium.Matrix4.fromTranslation( new Cesium.Cartesian3(0, 0, deltaHeight) ); return Cesium.Matrix4.multiply( translation, originMatrix, new Cesium.Matrix4() ); }注意这里的位移方向是 Z 轴。在 Cesium 的坐标系里如果 tileset 是用经纬度定位加载的tileset 局部坐标系通常还是 ENU东-北-天Z 轴指天所以竖直方向就是 Z。但如果你做过模型旋转、或者用 Cesium 的 modelMatrix 做了倾斜校正Z 轴就不一定严格指天了。稳妥的做法是先在控制台打印几个楼层节点的 worldMatrix确认位移方向后写死一个轴向或者通过向量换算得到真正的“楼层高度方向”。为了做抽屉动画我维护一个对象记录每个楼层当前的目标偏移量和实时偏移量。动画循环里用缓动函数插值更新每一层的矩阵const floorNodes new Map(); // floorKey - { originMatrix, currentMatrix, node } function updateDrawer(progress) { for (const [floorKey, item] of floorNodes) { const targetDelta item.targetDelta || 0; const currentDelta item.currentDelta || 0; const delta currentDelta (targetDelta - currentDelta) * progress; item.currentDelta delta; const newMatrix createFloorTransform(item.originMatrix, delta); item.node.transform newMatrix; } }动画主体用 requestAnimationFrame跑 500 毫秒左右加一个 CUBIC_IN_OUT 缓动。第一次试的时候我直接线性插值看起来也还行但楼层停稳那一刻会有微小的“顿挫感”换成缓动函数后整个过程的“抽屉感”就出来了。function animateDrawer(targetFloorKey, duration 500) { // 先计算每层的目标位移量 const targetDeltaMap calculateTargetDeltas(targetFloorKey); for (const [floorKey, item] of floorNodes) { item.targetDelta targetDeltaMap.get(floorKey) || 0; } const start performance.now(); function tick(now) { let t (now - start) / duration; if (t 1) t 1; const eased Cesium.EasingFunction.CUBIC_IN_OUT(t); updateDrawer(eased); if (t 1) { requestAnimationFrame(tick); } } requestAnimationFrame(tick); }calculateTargetDeltas 的策略我一开始想得很简单目标楼层往上拉一个固定高度其他楼层不动。实际试了下效果不好——其他楼层还在原位置目标楼层被拉出去之后从侧面看楼层之间有空洞不像是抽屉倒像是楼层“飞起来了”。后来改成这样以目标楼层为基准目标楼层保持不变或者略微上抬比它低的楼层整体向下压缩比它高的楼层整体向上压缩。一句话拉开后目标楼层在视觉上仍然处于楼体的中间位置周围楼层向两侧退让。这样才是真实的抽屉感。function calculateTargetDeltas(targetFloorKey) { const sortedFloors [...floorNodes.keys()].sort(compareFloor); const targetIndex sortedFloors.indexOf(targetFloorKey); const deltaMap new Map(); const gap 2.0; // 层间间隙按你的模型实际高度调整 sortedFloors.forEach((floorKey, index) { if (floorKey targetFloorKey) { deltaMap.set(floorKey, 0); } else if (index targetIndex) { // 目标楼层以下的层往下退让 const offset gap * (targetIndex - index); deltaMap.set(floorKey, -offset); } else { // 目标楼层以上的层往上退让 const offset gap * (index - targetIndex); deltaMap.set(floorKey, offset); } }); return deltaMap; }3.4 点击拾取与相机联动抽屉动画有了现在要解决的是“点哪层拉哪层”。点击拾取楼层可以用 Cesium 的 ScreenSpaceEventHandler拾取到 3DTilesFeature 后读取 floor 属性然后触发抽屉动画。const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction(function (movement) { const picked viewer.scene.pick(movement.position); if (!Cesium.defined(picked)) return; // 判断是不是 3DTiles feature if (picked.id instanceof Cesium.Cesium3DTileFeature) { const floor picked.id.getProperty(floor); if (floor) { handleFloorClick(String(floor).trim()); } } }, Cesium.ScreenSpaceEventType.LEFT_CLICK); function handleFloorClick(floorKey) { // 如果已经有抽屉打开先关闭或者直接切换 animateDrawer(floorKey, 600); // 相机飞到目标楼层附近 flyToFloor(floorKey); }相机联动这里有个小技巧。直接 viewer.flyTo(tileset) 会把整栋楼居中拉开抽屉后视角不一定好。我试了几种方式最终用的是先定位到 tileset 的包围盒再叠加上一个中心点偏移让视角中心落在目标楼层的 z 位置。function flyToFloor(floorKey) { const boundingSphere tileset.boundingSphere; const center boundingSphere.center.clone(); // 拿到目标楼层节点的世界中心如果你有 floorNodes 的 originMatrix 可以直接算 const floorItem floorNodes.get(floorKey); if (!floorItem) return; const floorWorldPos Cesium.Matrix4.getTranslation(floorItem.node.worldMatrix, new Cesium.Cartesian3()); viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees( Cesium.Math.toDegrees(floorWorldPos.longitude), // 实际不是这么取经纬度下面补充 0, 0 ), orientation: { heading: viewer.camera.heading, pitch: -Cesium.Math.PI / 5, roll: 0 } }); }严格来说拿到 worldMatrix 位移后得到的是 Cartesian3 世界坐标要转成经纬度需要 Cesium.Cartographic.fromCartesian。上面代码里的 longitude 写法是示意别直接拷进项目。正确的写法是const carto Cesium.Cartographic.fromCartesian(floorWorldPos); const lon Cesium.Math.toDegrees(carto.longitude); const lat Cesium.Math.toDegrees(carto.latitude); const height carto.height 30; viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(lon, lat, height), orientation: { heading: viewer.camera.heading, pitch: -Cesium.Math.PI / 6, roll: 0 } });到这里抽屉效果的核心闭环已经通了加载楼栋模型 → 提取楼层分组 → 点击楼层 → 动画面板计算位移 → 更新节点矩阵 → 相机飞到目标层视角。整套流程跑通了剩下的就是填充业务数据房间、租户、设备和打磨交互细节。4. 常见问题与排查实录4.1 楼层飞出外太空做抽屉效果时最容易遇到的情况是动画一执行楼层直接飞到天上去或者模型整体错乱。我第一次跑的时候也是这样。原因基本都在 transform 叠加时搞混了坐标系。你要记住一个原则所有楼层的位移都应该在 tileset 的局部坐标系里做而不是叠加到 worldMatrix 上。如果在修改节点 transform 时不小心用了世界坐标的绝对位置比如把一个局部坐标的 z 值替换成了经纬度高度值楼层必然飞走。排查方法很简单在动画执行前先打印目标楼层节点的 worldMatrix 和 transform动画执行后再打印一次对比一下位移距离是不是你预期的几米。如果位移了十几万米那一定是坐标叠加出了问题。另外要特别留意tileset.modelMatrix 不要轻易动。如果你把整个 tileset 做了旋转和缩放楼层节点的本地 Z 轴就不一定是竖直方向位移方向会偏。这种情况下需要把位移向量先变换到节点本地坐标系再做矩阵乘法。4.2 楼层之间闪铄、互相穿透抽屉拉开后楼层之间如果距离设定得太小会出现两个问题一是楼层边缘轮廓闪烁术语叫 Z-fighting二是看穿到下一层的构件。这两个现象本质上是同一件事——拉开后的层间距小于模型本身的误差或者构件厚度。解决方法有三个。第一个是调大抽屉间隙。gap 值不要固定在 2我建议你根据模型的实际层高动态计算比如取模型 boundingSphere.radius 的 1/30或者直接读楼层节点包围盒的尺寸来推算层高。这样无论模型是高矮胖瘦间隙比例都统一。第二个是给位移后的楼层加 polygonOffset。Cesium 里可以在模型材质上设置 depthTestAgainstTerrain 和 polygonOffset但 3DTiles 的 feature 级控制不便。更实用的是在数据转换阶段把楼层之间的构件预留几厘米的间隔距离避免贴脸重叠。第三个是渲染层面的退让。抽屉拉开过程中把非目标楼层的透明度降到 0.3~0.5或者加上一个压暗处理让目标楼层的结构更突出同时也掩盖一部分穿插瑕疵。透明叠加性能差是真但只持续动画那 500ms实际帧率影响不大。4.3 点击拾取拿不到楼层属性上周有同事问我说楼层点击为什么控制台打印出来 floor 是 undefined。我让他打印了 picked.id 的所有属性发现属性名是 floorNumber不是 floor。这种情况非常常见。不同建模软件、不同转换工具产出的 batchTable 字段名千奇百怪有叫 floor、Floor、FLOOR_CN、楼层、楼层号甚至有的是设计院的部门编号B1F、L2、5A。所以提取楼层的函数里要做一个字段归一化而不是硬编码一个属性名。我一般会写一个兜底函数按优先级依次取function getFloorProperty(feature) { const candidates [floor, Floor, FLOOR, floorNumber, 楼层, 楼层号]; for (const key of candidates) { const v feature.getProperty(key); if (v ! undefined v ! null) return v; } return undefined; }另一个常见问题是用 CesiumLab 转 3DTiles 时没有勾选“保留属性”。很多建模软件导出的原始模型根本不带属性转换工具没法凭空变出来。遇到这种情况只能在转换前到 CesiumLab 的属性配置页里手动关联表格把楼层号、户号关联进去。这是数据层面的问题代码里再怎么兼容都没用。4.4 帧率掉一半透明与动画性能抽屉效果做完后我发现在拉开状态下拖动相机帧率从 60 掉到 35 左右。排查了一圈主要拖累是透明的非目标楼层。Cesium 的透明渲染走的是 alpha 混合管线几十层半透明楼板叠在一起整个渲染批次复杂度暴涨。我的优化方案是抽屉拉开后非目标楼层不改成半透明而是改成暗色块不透明用一个深色颜色叠加方案代替透明度方案。这样既不丢失“楼还在”的上下文又保住了性能。3DTiles 的 feature 变色可以用 Cesium.ColorBlendMode 来做。如果你把非目标楼层的 feature 直接用 getFeature(i).color 改成灰色渲染上也是不透明的性能开销就小很多。如果你实在需要半透明那就得在数据端把每层拆成独立 tileset配合 Cesium 的 skipLevelOfDetail 和动态显隐来控制透明层数。同一个 tileset 内部半透明叠加性能很难救回来。4.5 楼层排序混乱抽屉效果里calculateTargetDeltas 需要把楼层按从低到高排序。如果楼层号是字符串直接 Array.sort 会出现“10F”排在“2F”前面的问题。楼层号还有可能带符号比如 -1F地下车库、B1、L2排序规则要提前定好。我用的方案是把楼层字符串拆成两部分地下层算负数地上层按数字排如果有字母前缀忽略前缀只取数字。代码写出来也不复杂但一定要在数据接入的时候就统一规则否则后面排到一半发现问题改起来要返工。我的建议是在 extractFloorMap 返回 Map 之前就把“楼层序号”数字解析出来存成一个字段比如 floorIndex。这样后面排序、计算 delta 全用 floorIndex避免字符串比较的坑。5. 延伸从抽屉效果到完整的分层分户产品5.1 和单体化方案的关系做完抽屉效果后你会发现它和 3DTiles 单体化的关系非常紧密。单体化本质上是让 3DTiles 里的每个“对象”能被单独选中、赋予属性、绑定交互。抽屉效果其实就建立在“楼栋被单体化成楼层”的基础上。常见的单体化有三种路线。人工建模单体化模型在建模软件里就是按构件拆好的每个构件绑定属性数据。抽屉效果用这种方式最顺手楼层天然就是单体。倾斜摄影单体化用倾斜摄影生产的实景模型表面是连续三角网没有内部构件。这种模型要做抽屉很难因为楼层边界得靠人工画线切割成本高、效果还容易穿模。id 单体化矢量面贴合在倾斜模型表面贴一层建筑底面矢量面用射线检测判断点击位置落在哪个面里。这种方式用来做属性拾取和弹窗展示最合适但做位移就无能为力了。所以做抽屉效果数据上天然适合“人工建模单体化”或者“BIM 模型轻量化”产出的 3DTiles。如果你手上拿的是倾斜摄影模型就别硬做抽屉了先考虑做单体化查询和按楼层透明度展示就好。我在项目里做的就是组合拳底层用倾斜摄影数据做园区底图楼栋用人工建模的白模做分层分户BIM 模型再挂到抽屉拉开后的楼层里。这样既能看到园区环境又能看到户内结构。5.2 后续可扩展的方向抽屉效果只是一个交互入口拉出来的楼层可以继续往下扩展。楼层展开后可以在该楼层上叠加设备点位、传感器、摄像头、工单位置等业务标记。点击某个标记还能联动楼层里对应的 3D 构件高亮。这就是典型的数字孪生运营场景了。还可以做户内单体化联动。点某层房间号摄像机继续下钻到户内视角同时旁边弹出户型的面积、租户、缴费记录。从楼栋 → 楼层 → 户内三级下钻的完整结构和抽屉效果天然衔接。如果你做的是 Cesium 3DTiles 的多栋楼场景抽屉效果还会牵涉到多 tileset 联动点击 A 栋某层时B 栋自动恢复收拢态避免场景里多个抽屉同时打开导致视觉混乱。这个逻辑可以在 handleFloorClick 里统一管理当前打开的楼层实现起来不难但产品层面要想清楚。另外值得提的是热力图、雷达扫描这类效果。楼层拉开后楼层平面是相对清楚的可以直接在楼层高度上叠加热力图纹理做人员密度、能耗分布分析。把抽屉层高和热力图数据绑定拉开的楼层自动重新生成热力图交互上非常亮眼。5.3 后端数据驱动的动态楼层配置上面所有逻辑看起来都是前端的事但也别忽略后端数据的配合。楼层的高亮、颜色、灰暗状态不应该写死在前端代码中最好是后端下发配置比如哪些层在特殊时期禁用抽屉效果、哪些层透明展示、哪些层需要伪装成房间级别而不是楼层级别。我把楼层配置抽象成了一个 JSON 对象下发前端{ buildingId: B-01, floors: [ { floor: 8F, drawerEnabled: true, highlightColor: #00CFFF }, { floor: 9F, drawerEnabled: false, highlightColor: #888888 } ] }前端读取配置后动态设定每层的抽屉开关这在多租户的建筑管理系统里很实用。同一栋楼物业端看到的抽屉效果是完整开放的租户端可能只允许拉出自己租的那一层其他楼层锁住。这种需求用同一个代码库可以低成本实现。6. 数据准备与关键陷阱建模端必须配合的三件事抽屉效果做到后期我发现问题几乎都出在数据端。如果你不想在代码里疯狂兼容建议在数据准备阶段就跟建模团队确认三件事。第一楼层对象必须独立。Revit、SketchUp 或 3ds Max 里建模时每一层的所有构件必须放在一个独立分组或图层中并且分组名统一带楼层号。比如“F08_墙体”“F08_门窗”。不然后续转换时 CesiumLab 无法自动生成楼层节点树代码就只能操作整个 tileset做不了抽屉。第二楼层号字段必须统一。前面说过楼层字段名五花八门是坑但更坑的是同一个人导出的两栋楼字段名都不一样。我的做法是和建模团队约好一个固定的属性命名规范比如统一用 floor取值统一为整数地下一层写 -1地下二层写 -2地面一层写 1以此类推。千万不要用“B1F”“F-1”“负一层”这些花式写法。第三禁用模型整体合并。一些导出工具或者转换工具默认会做网格合并优化把同一楼层里的所有小构件合并成一个 mesh这其实不影响楼层分组但会影响构件级单体化。如果后续要做房间级点击和属性查询就不要勾选“全部合并为一个节点”的选项至少要保留到楼层分组的层级。我在项目里吃过一个很大的亏模型转换后所有楼层节点名字变成了 tile_0、tile_1、tile_2完全看不到原始对象名。原因是导出 glTF 时勾选了“自动简化节点层级”把带楼层名字的对象层级打平了。后来我要求建模团队重新导出保留原始节点树问题才解决。7. 最后再分享两个实用调试技巧第一个技巧抽屉动画调试时千万别反复点楼层。每次点击都触发 500ms 动画频繁点击会把 requestAnimationFrame 的循环压满视觉上看起来卡顿甚至节点矩阵被写乱。我给 handleFloorClick 加了一个锁动画进行中时忽略新的点击等动画结束后再响应下一次点击。这样既能避免动画相互覆盖也能减少调试时的困惑。let isAnimating false; async function handleFloorClick(floorKey) { if (isAnimating) return; isAnimating true; try { await animateDrawer(floorKey, 600); } finally { isAnimating false; } }第二个技巧每一次修改节点 transform 之后要手动调用一次 tileset.update() 或者让 Cesium 的渲染循环接手更新。3DTiles 的内部包围盒在 transform 变化后不会立刻重新计算如果你频繁操作节点后又去拾取可能会因为包围盒没更新而拾取不到。我在动画结束的下一帧强制调用 viewer.scene.requestRender()确保渲染器刷新一次。这两个技巧都是实战里很容易踩的隐性坑代码逻辑看着没问题但实际运行就是不流畅、点不上多半就是这些渲染更新和动画竞争在作怪。最后说说我自己的感受分层分户抽屉效果没那么难关键是理解 3DTiles 的 transform 机制和数据准备。你只要把楼层分组的节点树搞明白动画本身只是矩阵乘法和缓动的组合不存在高深算法。反而是那些听起来基础的数据规范、字段统一、模型导出设置决定了整个功能的稳定上限。希望这篇文章能帮你少走一轮弯路欢迎有问题在评论区交流。