前阵子接了个智慧楼宇的项目,甲方提了一个很具体的要求:点一下楼层,整栋楼要能像抽屉一样把那一层拉出来,其他楼层自动收起来。这个需求在智慧园区、房产展示、物业管理里非常常见,说白了就是给 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 之后,楼层对象往往还在——比如根节点下面有 FL00R=1、FL00R=2 这样的子节点,每个节点里挂着一整层的墙体、门窗、家具构件。对这种数据,用 Cesium 加载后是可以直接在节点树里找到楼层根节点的,修改这个节点下的 transform 就能做整体位移,非常优雅。
但如果你拿到的是 CesiumLab 一键转出来的 b3dm,且原始模型没有保留对象分组,那 tileset 的节点树很可能只有一个根节点,整个楼是一个整体,无法按楼层拆开。遇到这种情况,要么回到建模软件重新导出,把每层单独命名;要么在转换工具里配置“按楼层分组”的选项;要么用方案 A,把每一层分别导出成独立的 3DTiles 文件,运行时用多个 tileset 管理。
CesiumLab 里做 shp 转 3DTiles 的时候,属性信息是可以带过来的,比如每栋楼的楼栋号、楼层号、户号、面积。这些属性会进入 b3dm 的 batchTable。Cesium 拾取到 3DTilesFeature 后,可以通过 getProperty 方法直接读取。所以做分层分户前,先把数据里的楼层字段统一好,建议至少包含这三个字段:
| 字段名 | 含义 | 示例 |
|---|---|---|
| building_id | 楼栋唯一标识 | B-01 |
| floor | 楼层号 | 8F、F08、8 |
| unit | 户/房间号 | 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 导出 fbx,CesiumLab 可以直接转 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 下面有 root,root 下面有 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 机制和数据准备。你只要把楼层分组的节点树搞明白,动画本身只是矩阵乘法和缓动的组合,不存在高深算法。反而是那些听起来基础的数据规范、字段统一、模型导出设置,决定了整个功能的稳定上限。希望这篇文章能帮你少走一轮弯路,欢迎有问题在评论区交流。