ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Cesium 3DTiles分层分户抽屉实现:从节点遍历到动画完整指南

2026/10/2 21:15:44 拓冰建站 浏览量
Cesium 3DTiles分层分户抽屉实现:从节点遍历到动画完整指南 去年接了个房产数字化项目客户提的需求很简单一栋楼的3DTiles模型要能像抽屉一样点某层就“抽”出来露出里面的户型结构。听起来不难真正动手才发现Cesium里做分层分户抽屉牵涉到数据组织方式、glTF节点访问、矩阵变换、动画插值这一整套链路任何一个环节没想清楚都会卡壳。这篇文章把我完整的实现思路、核心代码、以及调试中踩过的坑全部整理出来给准备做同类功能的同学一条可行的捷径。1. 先想清楚再做两条技术路线怎么选1.1 分层分户抽屉到底在解决什么问题先说场景。这类功能最常见于房产售楼系统、楼宇招商平台、物业管理后台和应急疏散演练。整栋楼以Cesium 3DTiles格式加载到三维地球后用户需要按楼层查看甚至按户型查看。传统做法是“切剖”用剖切面把楼体切开但这种方式对操作要求高普通用户根本玩不转。抽屉效果把“剖切”变成“抽拉”——楼层从楼体中水平滑出内部户型自然暴露交互直觉非常清晰。本质上是解决三维模型的信息层级展示问题楼栋、楼层、户三元层级用户需要自由下钻又不能丢失整体空间位置关系。1.2 方案A模型预处理拆分把BIM模型Revit、SketchUp、Blender都行按楼层拆分分别导出切片成多个3DTiles每个楼层是一个独立的tileset.json。前端维护一个楼层数组点击楼层时设置对应tileset的偏移量实现抽屉效果。优点很突出每个楼层独立加载按需流式加载内存占用小可以做精确的楼层权限控制比如某层只有特定用户能看修改某层模型只需重新生成该层的3DTiles不用全量重出抽屉动画简单只需要改tileset.modelMatrix缺点同样明显建模阶段就要把楼层拆开如果模型已经是一个整体拆分工作量大楼层之间的“缝”容易在视觉上接不上后续新增楼层要重新走一遍生产管线。1.3 方案B单模型加节点变换模型保持原始完整结构导出一个3DTiles在前端加载后通过访问glTF内部节点对单个楼层节点做矩阵变换。Cesium 3DTiles内部的b3dm、glTF是有节点树结构的BIM模型如果命名规范楼层和户型都能按名称找到。优点原始模型不拆避免接缝问题和数据重复运行时可动态控制任意粒度的节点楼层、户型、墙体都能动适合做交互式拆解抽屉只是其中一种动画形式缺点依赖模型内部命名规范命名混乱时定位节点非常痛苦前期开发调试成本高3DTiles节点树不是直观可见的DOM树数据量大的楼栋一次性加载压力大我做项目时最终选了方案B为主、方案A为辅的混合策略楼体整体加载但在建模阶段就要求Revit导出的模型按楼层命名了节点。如果你的项目模型还没建强烈建议在建模阶段就做节点命名规范后面省的时间远大于前期沟通成本。对比项方案A预处理拆层方案B单模型节点变换模型改动量大需要按楼层拆模导出小只需规范命名加载策略每层独立流式整栋加载动画控制控制tileset整体位移控制内部节点位移权限控制天然支持按层加载需要额外逻辑开发调试成本低较高适合场景超大体量楼宇、需权限隔离中等体量、交互拆解为主2. 数据侧准备确保节点能拿到、能识别2.1 控制好3DTiles的生产参数模型导出3DTiles时有几个参数直接影响你能不能做分层分户。不要设置clampToGround: true。一旦贴地放置所有楼层会被压扁到地表高度信息丢失分层无从谈起。保留模型原始地理坐标和高度生成后检查tileset的boundingSphere是否覆盖了楼体的完整高度范围。不要过度压缩几何。有些转换工具默认开Draco压缩压缩后节点层级和名称可能丢失。我用的生产链路是Revit导出FBX - gltf-pipeline转glTF - 3D Tiles工具切片。FBX转glTF时保留节点层级之后切片到3DTiles时节点名仍然能保留。网上很多人在问“shp转3dtiles”“fbx转3dtiles”“cesium模型可以直接加载su吗”——如果你是从GIS数据转通常没有内部节点概念如果是建筑模型su、fbx、rvt节点名是保留的。倾斜摄影模型基本没有语义化节点直接放弃方案B先用单体化处理或手工重建简化模型。2.2 给楼层和户型的命名约定这是整个项目的隐性关键。Demo模型随便起名无所谓生产项目必须约定楼层节点统一前缀floor_1、floor_2、floor_3户型节点统一前缀room_1_11层1号房、room_1_2公共区域lobby_1代表1层大堂遍历节点时通过名称解析比通过位置计算稳定得多。有一版我在没有命名规范的情况下只能靠节点包围盒的高度反推楼层一旦模型有地下层、夹层、设备层反推逻辑写到你怀疑人生。2.3 没有规范命名的兜底方案老模型实在改不了也不是完全没救。可以按节点包围盒中心点的Z坐标来分层function getFloorIndexFromNode(node, baseHeight, floorHeight) { const boundingSphere node.boundingVolume?.boundingSphere; if (!boundingSphere) return null; const centerHeight boundingSphere.center.z; const index Math.round((centerHeight - baseHeight) / floorHeight); return index; }baseHeight取楼栋最低点floorHeight取标准层高。注意地下层需要另做映射比如地下室层高4.5米、地上标准层高3米混在一起计算会错位。这种方案适合应急验证生产环境我不推荐。3. 抽屉效果的本质两套“移动”逻辑3.1 抽屉是纯平移不是旋转也不是缩放很多人第一次做拆解动画会想到旋转展开——像门一样打开。但抽屉效果的本质是纯平移把目标楼层沿某个水平方向平移到楼体轮廓外同时保证平移方向可预测、可逆。平移的距离单位是米。Cesium的笛卡尔坐标Cartesian3单位是米但注意你操作的是地心坐标系下的绝对坐标还是模型局部坐标。平移楼层时正确做法是在模型局部坐标系里做偏移再通过模型矩阵转换到世界坐标。3.2 平移作用在哪个层级这是容易混淆的地方平移整个tileset通过tileset.modelMatrix做但这样整栋楼一起动不是分层分户平移某个楼层对应的glTF节点推荐方案平移户型子节点需要拿到楼层节点下的子节点逐个处理在Cesium的3DTiles内部每个tile的content可能是b3dm、pnts等格式。b3dm内部包含一个glTF模型这个模型的节点树才是我们操作的对象。旧版本Cesium可以通过tileset._root._content._model._getNodeByName(name)拿节点不同版本内部结构有差异这一点项目里一定要锁定Cesium版本。3.3 动画插值决定手感抽屉不能“瞬移”要能看到抽拉过程。Cesium没有内置补间动画器我采用viewer.clock.onTick监听每一帧根据时间差计算插值系数再用矩阵运算设置节点的位移量。插值系数用简单的线性插值就能看但视觉偏生硬。稍微好一点用缓动函数function easeInOutCubic(t) { return t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2; }实测下来抽屉动画用easeInOutCubic或easeOutQuad观众视觉感受最舒服拉开的前半段稍快快到位时刹住很像真实抽屉的惯性感。4. 可复现的完整代码加载3DTiles并实现分层分户抽屉4.1 场景初始化和3DTiles加载先初始化Viewer关闭不相关的默认控件加载tileset时关闭clampToGround并把maximumScreenSpaceError调大减少渲染压力。const viewer new Cesium.Viewer(cesiumContainer, { animation: false, timeline: false, terrainProvider: Cesium.createWorldTerrainAsync() }); // 背景和光照的调整让模型内部结构看得更清楚 viewer.scene.globe.baseColor Cesium.Color.fromCssColorString(#2b2b2b); viewer.scene.light new Cesium.DirectionalLight({ direction: new Cesium.Cartesian3(-0.5, -0.5, -1), intensity: 0.8 }); const tileset await Cesium.Cesium3DTileset.fromUrl(/data/building/tileset.json); tileset.maximumScreenSpaceError 16; viewer.scene.primitives.add(tileset); await viewer.zoomTo(tileset, new Cesium.HeadingPitchRange(0, -0.6, 300));这里zoomTo不只是起飞视角它会让相机定位到tileset包围球适合观看的位置。注意createWorldTerrainAsync是异步的如果不需要地形可以去掉直接Cesium.Cartesian3.fromDegrees设置相机位置。4.2 等3DTiles加载完成再遍历节点这是新手最容易踩的坑3DTiles是流式加载的马上遍历tileset.root时内部几何可能还没到位。需要等待加载完成。await Cesium.when(tileset.readyPromise, async () { // 确保所有待加载的Tile都渲染完成 const allLoaded () tileset.root.contentReady || tileset.root.contentAvailable; // 简单稳妥做法均匀延时轮询检查 await waitForTilesLoad(tileset); setupFloorNodes(tileset); });waitForTilesLoad是一个轮询函数检查tileset._root._content是否存在且content._model已创建。注意_root开头的方法都是Cesium内部API新版本可能变化生产代码要锁定版本并包一层Try-Catch。4.3 遍历glTF节点收集楼层拿到模型对象后按命名约定遍历节点。我封装了一个按名称前缀匹配节点的函数function findNodesByPrefix(model, prefix) { const matched []; model._getNodeNames?.()?.forEach((name) { if (name.startsWith(prefix)) { matched.push({ name, node: model._getNodeByName(name) }); } }); return matched; }有些Cesium版本没有_getNodeNames就手动递归遍历model._rootNode的childrenfunction traverseGltfNode(node, callback) { if (!node) return; callback(node); const children node._children || node.children; if (children) { children.forEach(child traverseGltfNode(child, callback)); } }遍历时注意glTF节点的访问要用内部属性和3DTiles的tile节点是两套体系。收集后保存每个节点的初始矩阵抽屉动画结束后要能恢复。4.4 核心楼层抽屉动画控制构建一个状态机保存每个楼层的节点、初始矩阵、当前偏移量、动画状态const floorState {}; function setupFloorNodes(tileset) { const model tileset.root.content.model || tileset._root._content._model; if (!model) return; const floorPrefix floor_; const roomPrefix room_; // 遍历模型节点找到所有楼层 traverseGltfNode(model._rootNode, (node) { if (node.name node.name.startsWith(floorPrefix)) { floorState[node.name] { node: node, originMatrix: node.matrix.clone(), offsetX: 0, isOpen: false, animation: null }; } }); }有了楼层节点做抽屉动画就是修改node.matrix。注意不能直接改节点的position属性因为glTF节点的position是局部坐标而我们需要的是相对模型的局部平移。正确做法是初始矩阵乘以平移矩阵。function setFloorOffset(floorName, offsetX, offsetY, offsetZ) { const state floorState[floorName]; if (!state) return; const translationMatrix Cesium.Matrix4.fromTranslation( new Cesium.Cartesian3(offsetX, offsetY, offsetZ) ); const newMatrix Cesium.Matrix4.multiply( state.originMatrix, translationMatrix, new Cesium.Matrix4() ); state.node.matrix newMatrix; state.offsetX offsetX; }抽屉效果的位移方向我选择沿X轴建筑开间方向向外平移。不同的模型在建模软件里朝向可能不同上线前要确认平移方向对用户体验友好。如果模型不是正北朝向用世界坐标偏移会出现斜向抽拉最好在局部坐标系做ENU变换。4.5 抽屉动画插值实现定义抽屉拉开角度/距离然后用时间差驱动插值。这里我用viewer.clock.onTick做帧监听function animateFloorDrawer(floorName, targetDistance, duration 1.0) { const state floorState[floorName]; if (!state) return; const startTime Cesium.JulianDate.now(); const startOffset state.offsetX; const targetOffset targetDistance; if (state.animation) { viewer.clock.onTick.removeEventListener(state.animation); } const update (clock) { const currentTime clock.currentTime; const ratio Math.min( Cesium.JulianDate.secondsDifference(currentTime, startTime) / duration, 1.0 ); const eased easeInOutCubic(ratio); const newOffset startOffset (targetOffset - startOffset) * eased; setFloorOffset(floorName, newOffset, 0, 0); if (ratio 1.0) { viewer.clock.onTick.removeEventListener(update); state.animation null; state.isOpen targetOffset ! 0; } }; state.animation update; viewer.clock.onTick.addEventListener(update); }这里有一个很重要的点Cesium的onTick事件在所有渲染帧都会触发如果多个楼层同时动画会有多个闭包同时存在注意清理监听器避免内存泄漏。我在state.animation里保存了当前动画函数新动画启动时先移除旧的。4.6 实现整栋楼的“全拉出”和“全收回”实际业务中用户不止是单层抽拉还有“一键全部拉开”的需求。做法就是遍历所有楼层先统一计算每个楼层抽屉的展开方向和距离避免层与层重叠然后逐层做动画错开启动时间形成顺序推拉的节奏function openAllDrawers(distance 30, staggerDelay 120) { const floorNames Object.keys(floorState); floorNames.forEach((name, index) { setTimeout(() { animateFloorDrawer(name, distance, 0.8); }, index * staggerDelay); }); }4.7 点击楼层实现交互最简单实用的方式不是直接拾取3DTiles节点而是给每个楼层添加一个透明拾取盒子。因为3DTiles内部的glTF节点拾取在不同的Cesium版本表现不稳定透明盒子方案稳定且易于控制还可以加点击反馈和描边。function createFloorPickBox(floorName, centerPosition, dimensions) { const entity viewer.entities.add({ name: pick_${floorName}, position: Cesium.Cartesian3.fromDegrees(centerPosition.lng, centerPosition.lat, centerPosition.height), box: { dimensions: new Cesium.Cartesian3(dimensions.x, dimensions.y, dimensions.z), material: Cesium.Color.WHITE.withAlpha(0), outline: false }, properties: { floorName: floorName } }); return entity; } const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((movement) { const picked viewer.scene.pick(movement.position); if (picked?.id?.properties?.floorName) { const floorName picked.id.properties.floorName.getValue(); toggleFloorDrawer(floorName); } }, Cesium.ScreenSpaceEventType.LEFT_CLICK);toggleFloorDrawer里根据isOpen状态决定是拉出还是收回。点击盒子所在楼层后如果已经展开就收回否则就展开。4.8 相机跟随与定位抽屉拉出后相机要能自动聚焦到被拉出的楼层。Cesium提供了viewer.flyTo来定位到实体但3DTiles内部的节点没有直接对外的Entity。需要手动计算节点包围盒的中心function flyToFloor(floorName, offset 50) { const state floorState[floorName]; if (!state) return; // 节点本身是glTF节点拿不到transform后的世界位置 // 用节点包围盒中心 当前tileset的modelMatrix换算 const tilesetPosition tileset.boundingSphere.center; const worldPosition Cesium.Matrix4.multiplyByPoint( tileset.modelMatrix, tilesetPosition, new Cesium.Cartesian3() ); viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees( Cesium.Math.toDegrees(worldPosition.longitude), Cesium.Math.toDegrees(worldPosition.latitude), worldPosition.height offset ), orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45), roll: 0 }, duration: 0.8 }); }5. 实际项目里容易踩的六个坑5.1 allTilesLoaded永远不触发很多教程都告诉你tileset.allTilesLoaded事件可以判断加载完成但这个事件在相机视角不断变化、Tile不断被替换时会反复触发甚至不触发。稳妥做法是不等这个事件而是用轮询检查tileset.root._content是否存在并已有_model配合一个超时上限。我在项目中用了50次轮询间隔100毫秒5秒内还没拿到模型就报错提示。5.2 修改了matrix但不生效或闪跳修改glTF节点node.matrix在大多数Cesium版本下一帧生效但如果修改的是_content里尚未加载完成的node操作会被后续的tile加载覆盖。解决办法确认在拿到_model之后再获取节点并保存初始矩阵动画期间避免摄像头大幅视角切换触发新的Tile细化。如果遇到修正了matrix又跳回原位检查是否修改的是node.transform而不是node.matrix。两者区别node.transform是glTF规范的标准属性为Matrix4node.matrix是Cesium对节点变换的内部存储旧版本Cesium中操作的是node.matrix新版里node.transform更稳定。5.3 抽屉拉开后楼层之间穿模这是最影响观感的问题。单纯平移只能拉开抽屉但相邻楼层之间的公共墙、管线还是会穿帮。两个针对手段平移距离必须大于被拉出楼层的最大轮廓尺寸让楼层完全脱离楼体轮廓剪裁平面配合在楼体侧面设置一个或多个ClippingPlane把当前楼体内部被遮挡的部分“切掉”const plane new Cesium.ClippingPlane( new Cesium.Cartesian3(0, 1, 0), // 法线方向 clipDistance );注意ClippingPlane是无限平面要配准确的法线和距离否则会把楼体外部也切了。我实测的结论动画时长在0.8秒时剪裁平面跟随抽屉距离线性移动视觉上几乎看不到穿模。5.4 平移距离的米制换算问题抽屉拉开距离不能拍脑袋定要根据模型的实际宽度算。比如楼体沿X轴方向宽度为32米那么拉出至少35米。用tileset.boundingSphere.radius * 2可以粗略估算楼体宽度更精确的做法是获取楼层节点的包围盒尺寸const boundingSphere Cesium.BoundingSphere.fromBoundingSphere( // 部分版本节点有boundingVolume state.node.boundingVolume?.boundingSphere );如果拿不到node级包围盒就用tileset的整体包围盒乘以缩放系数。开发阶段在UI上加一个distance滑动条动态调整是最好的调参方式。5.5 倾斜摄影模型根本做不了节点抽屉很多网友搜“cesium倾斜摄影”进来想给倾斜摄影模型做分层分户。这里必须泼冷水倾斜摄影的3DTiles是密集Mesh没有语义化的楼层节点node名全是随机字符串。要做分层分户只能用单体化工具把倾斜模型按建筑底面切割或者重建简模BIM手工建模/自动建模或者对OSGB数据进行压平和分类倾斜摄影适合全貌查看、量测不适合做精细分层。我把这个写在文章里希望做选型时少走弯路。5.6 Cesium版本差异是最大的隐藏成本1.x阶段Cesium迭代非常快3DTiles内部实现频繁调整。项目里用某版本写好的_root._content._model._getNodeByName过两个季度升级Cesium后可能完全失效。项目经验锁定Cesium版本升级做专项回归。每次升级先跑节点遍历自测再跑抽屉动画最后检查相机拾取。6. 从抽屉到成品我的落地功能清单和建议扩展6.1 基础功能清单最终交付给客户时做的分层分户抽屉功能包含以下模块模块实现方式优先级楼层抽屉点击楼层水平拉出P0一键全展开/全收回全部楼层顺序拉出P0楼层定位拉出后相机跟随P1户型高亮拉出后对应户型变色P1信息面板点击户型显示面积/户主P1剖切联动楼层展开时自动剖切遮挡P26.2 结合单体化做户型信息联动倾斜摄影做不了抽屉但是正射场景结合“单体化”可以做户型点击信息联动。所谓单体化本质上是对3DTiles的Feature增加属性让前端通过Cesium3DTileFeature读取batchId对应的属性值然后弹窗显示。这个和抽屉效果可以混搭楼体用BIM做抽屉周边环境用倾斜摄影做单体化房地产项目经常这么组合。6.3 配合动态光照和热力图做营销效果抽屉效果做出来后客户往往会追加两类效果一是动态光照模拟一天内太阳光移动照射到拉出的楼层和户型售楼处大屏上效果拔群。实现上用viewer.scene.light的direction随时间变化即可注意在太阳转至建筑背面时室内光线过暗需要加环境光保底。二是热力图按楼层/户型展示认购率、空置率。可以在地面叠加一个热力图图层也可以给已售户型做颜色覆盖。数据量小时用Entity的Polygon拾取数据量大时用GeoJSON加载再样式映射。6.4 鹰眼和联动定位详情页和总览页之间需要“鹰眼”地图做定位反馈。Cesium实现鹰眼最简单方式是用一个右下角的小Cesium.Viewer跟随主相机同步视角或者用2D地图如Leaflet做同步定位。我项目里用的是第二个Viewer同步的方式代码量少且可以支持3D联动视角。6.5 稳定性和老旧设备降级抽屉动画底层依赖每帧矩阵计算老旧电脑或浏览器如果不支持WebGL 2.0大概率白屏卡顿。建议做能力检测const canvas document.createElement(canvas); const gl canvas.getContext(webgl2) || canvas.getContext(webgl); if (!gl) { // 提示用户升级浏览器或使用支持WebGL的设备 }同时把Cesium的scene.debugShowFramesPerSecond暴露给内部测试目标设备上帧率低于25fps时降低maximumScreenSpaceError并减少同时展开的楼层数。我自己在这个项目里最后沉淀下来的体会是分层分户抽屉谈不上高深算法但非常考验工程串联能力。数据侧命名规范能省一半前端时间模型侧层级结构决定你能做多细的节点控制而前端侧把矩阵变换、动画插值和拾取交互想清楚就成功了大半。如果你正准备做这个功能建议先拿一个命名规范的测试模型跑通节点遍历和矩阵动画这一小段再铺开到完整业务不要一上来就调大模型。记住先把一个楼层拉出来再谈整栋楼。