用 AI 写了个脚本,把 13 万栋北京建筑全渲染进了 Cesium
城市建筑白模是 Cesium 里很常见的一种可视化效果:一栋栋灰白色的几何体铺满城市,没有贴图,没有材质,但能让人一眼看清城市的空间格局。
要在 Cesium 里跑起来这个效果,需要经历两段路:一是找到原始数据并完成格式转换,二是在前端正确加载和渲染。本文从最原始的 OpenStreetMap 数据出发,经过一段 Python 脚本的加工,最终在 Cesium 里加载出北京的建筑白模,把这两段路完整走一遍。
最终效果如下:

前半部分聚焦 Cesium 这一侧——如何加载 3D Tiles,以及底图、相机、样式等常用 API 的用法;后半部分回到数据生产,看 OSM 原始数据是怎么一步步变成 Cesium 可以加载的格式的。
案例仓库:https://github.com/calmound/cesium-osm-building (opens in a new tab)
这个仓库已经包含所有必要文件,clone 下来即可直接运行,不需要自己跑转换脚本:
- 原始 OSM 数据:
beijing-260327.osm.pbf - 转换脚本:
osm_buildings_to_3dtiles.py - 已转换好的 3D Tiles:
tileset_output/tileset.json - Cesium 前端工程:
frontend-demo
在 Cesium 里加载建筑白模
案例启动
clone 仓库之后,进入前端工程目录:
cd frontend-demo
npm install
npm run dev打开终端输出的本地地址(通常是 http://localhost:5800),将相机高度往下移动到合适的位置,就能看到北京建筑白模。
Cesium 加载的是什么格式
先弄清楚一件事:Cesium 不认识 .osm.pbf,也不认识 GeoJSON 或 Shapefile。
Cesium 只认识 3D Tiles。
3D Tiles 是 Cesium 生态里的空间数据格式,专门为大规模三维场景设计。你可以把它理解为一套"分级切片"的三维数据包——类似于地图服务里的瓦片,但存储的是三维几何体。
一套 3D Tiles 的结构是这样的:
tileset.json:入口文件,描述整个数据集的空间组织方式*.b3dm:实际的瓦片文件,每个文件包含一批三维建筑的几何数据
所以 Cesium 加载建筑白模的过程,本质上是:读取 tileset.json,按需请求对应的 b3dm 文件,渲染成屏幕上的几何体。
OSM 数据是什么
OpenStreetMap(OSM)是一个由全球志愿者协作维护的开放地图数据库,任何人都可以编辑和使用其中的地理数据。
OSM 的数据模型基于三种基本要素:
- Node(节点):带经纬度的点,可以是单独的兴趣点,也可以是构成 Way 的顶点
- Way(路径):由一组节点连成的线或面,道路、建筑轮廓都属于这一类
- Relation(关系):多个 Way 或 Node 的组合,用于表示复杂要素,比如带内环的建筑多边形
每个要素都可以附加任意标签(tag),采用键值对形式,建筑数据长这样:
building = yes
building:levels = 10
height = 32
name = 国贸大厦.osm.pbf 是 OSM 数据的二进制压缩格式(Protocol Buffer Format),相比 XML 体积小很多,是 Geofabrik 等平台分发 OSM 数据的标准格式。一个北京区域的 .osm.pbf 包含了这座城市几乎所有地物——道路、建筑、水系、公园等。
但这套数据是二维的。它记录了建筑的地面轮廓和属性标签,没有三维几何。要把它变成 Cesium 可以渲染的建筑白模,需要一层加工,第二部分专门处理这件事。
了解了 Cesium 所需的数据格式和 OSM 原始数据的结构之后,接下来结合示例工程 frontend-demo/src/main.js (opens in a new tab) 的代码,逐步介绍 Cesium 加载建筑白模涉及的核心 API。
初始化 Viewer
Cesium 里一切的起点是 Viewer,它负责创建渲染容器、管理相机、底图、场景等所有东西。
const viewer = new Viewer("cesiumContainer", {
animation: false,
baseLayerPicker: false,
fullscreenButton: false,
geocoder: false,
homeButton: true,
infoBox: false,
sceneModePicker: false,
selectionIndicator: false,
timeline: false,
navigationHelpButton: false
});第一个参数是 HTML 容器的 id,第二个参数是配置项。默认情况下 Cesium 会在页面上渲染一大堆内置 UI 控件(时间轴、动画、地理搜索框等),对于只展示建筑白模的场景来说这些都用不到,所以这里把多余的控件全部关掉,只保留了 homeButton(返回初始视角的按钮)。
创建 Viewer 之后,可以进一步调整场景的细节:
// 关闭深度测试(防止白模被地形遮挡,地形起伏不大时常用此设置)
viewer.scene.globe.depthTestAgainstTerrain = false;
// 大气层亮度微调,让画面不那么曝
viewer.scene.skyAtmosphere.brightnessShift = -0.1;
// 限制最大俯仰角,防止相机翻转到地球背面
viewer.scene.screenSpaceCameraController.maximumTiltAngle = CesiumMath.toRadians(89);加载 3D Tiles
把一套 3D Tiles 加入场景,核心代码如下:
const tileset = await Cesium3DTileset.fromUrl("/tileset_output/tileset.json", {
maximumScreenSpaceError: 64
});
viewer.scene.primitives.add(tileset);fromUrl是异步方法,它会请求tileset.json并解析出整个数据集的空间结构maximumScreenSpaceError控制 tile 的加载精度:值越小,加载越精细,性能消耗越高;值越大,加载越粗糙,但性能更好。64是一个适合城市白模概览的折中值viewer.scene.primitives.add(tileset)把数据集注册进场景。Cesium 里的primitives是直接渲染三维几何体的容器,3D Tiles 走的就是这条路
为什么用 primitives 而不是 entities?
Cesium 有两套添加内容的 API:entities 适合管理少量有属性的地理要素(比如标注点、路线);primitives 适合大规模几何渲染,性能更高。建筑白模有十万级别的要素,必须走 primitives。
底图加载与切换
Cesium 的底图系统通过 viewer.imageryLayers 管理,它是一个图层栈——可以叠加多个底图,也可以随时增删。
本示例工程里预置了两套底图:
const imageryProviders = {
// Cesium Ion 的卫星影像底图
ion: await createWorldImageryAsync(),
// CartoDB 的浅色矢量底图(通过标准 XYZ 瓦片 URL 接入)
light: new UrlTemplateImageryProvider({
url: "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
credit: "CARTO"
})
};createWorldImageryAsync() 是 Cesium 内置的卫星影像,分辨率高,适合配合建筑白模做整体鸟瞰。UrlTemplateImageryProvider 则是通用的 XYZ 瓦片接入方式,只要提供符合 {z}/{x}/{y} 格式的底图服务地址就能使用,市面上大多数地图服务都支持这种格式。
切换底图时,先清空当前图层,再加入新的:
viewer.imageryLayers.removeAll();
viewer.imageryLayers.addImageryProvider(provider);示例工程里还有第三种模式——"关闭"底图,也就是完全隐藏地球:
viewer.scene.globe.show = false;关掉地球之后,背景变成纯色,建筑白模的颜色也会同步调亮,形成高对比的浏览效果,适合截图或仔细查看建筑细节。

相机控制
Cesium 里的相机定位不像普通地图那样只有经纬度,它是真正的三维相机,需要指定位置、朝向和俯仰角。
最常用的方式是 camera.flyTo:
await viewer.camera.flyTo({
destination: Cartesian3.fromDegrees(116.397, 39.908, 1800),
orientation: {
heading: CesiumMath.toRadians(20), // 方位角(偏北多少度)
pitch: CesiumMath.toRadians(-38), // 俯仰角(负值表示向下看)
roll: 0
},
duration: 1.8 // 飞行动画时长(秒)
});Cartesian3.fromDegrees(经度, 纬度, 高度) 把地理坐标转换成 Cesium 内部的三维笛卡尔坐标,高度单位是米。
示例工程里预置了三个相机位置:
| 预设 | 高度 | 用途 | |||| | 核心区 | 1800m | 俯瞰北京城区,建筑密集 | | 近景 | 650m | 接近街道高度,感受单栋建筑体量 | | 远景 | 12000m | 整体鸟瞰,看城市空间分布 |

另外一种常用方式是 flyToBoundingSphere,它根据数据的实际空间范围自动计算飞行目标,不需要手动指定坐标:
await viewer.camera.flyToBoundingSphere(
tileset.boundingSphere,
{
duration: 1.8,
offset: new HeadingPitchRange(
CesiumMath.toRadians(18), // heading
CesiumMath.toRadians(-38), // pitch
range // 距离包围球边缘的距离
)
}
);这在数据加载完成后首次定位时很好用,因为不需要提前知道数据在哪里。
白模样式与调试
给白模设置颜色
3D Tiles 支持通过 Cesium3DTileStyle 统一设置样式,颜色用 CSS 颜色字符串表示:
tileset.style = new Cesium3DTileStyle({
color: "color('#dbe4ec', 1.0)" // 浅蓝灰色,第二个参数是不透明度
});示例工程里在切换底图时也会同步调整白模颜色——普通底图用浅灰蓝,关闭底图后用更亮的近白色,让白模在不同背景下都有合适的对比度。
调试 tile 结构
开发过程中排查数据或渲染问题时,两个调试属性很常用:
// 显示每个 tile 的三角面网格
tileset.debugWireframe = true;
// 显示每个 tile 的空间包围盒
tileset.debugShowBoundingVolume = true;线框模式可以直观看出每个 tile 包含多少几何,包围盒模式可以看出 tile 的空间切分方式,以及哪些 tile 当前在相机视锥内被加载。

监听加载事件
3D Tiles 是流式加载的,Cesium 提供了几个事件可以监听加载进度:
// 初始视角内的 tile 加载完成
tileset.initialTilesLoaded.addEventListener(() => { ... });
// 所有当前请求的 tile 加载完成
tileset.allTilesLoaded.addEventListener(() => { ... });
// 某个 tile 加载失败
tileset.tileFailed.addEventListener((event) => {
console.error(event.url, event.message);
});tileFailed 在排查白模加载不完整的问题时很有用,通常是路径错误或文件缺失导致的。
第一部分小结
Cesium 这一层只负责"显示"。核心的几个概念:
Viewer是整个场景的容器imageryLayers管理底图,支持随时切换camera.flyTo控制相机飞行,需要指定位置和朝向- 3D Tiles 通过
primitives.add加入场景,流式按需加载 Cesium3DTileStyle控制白模外观
只要有一套格式正确的 3D Tiles,以上这些就足以把它展示出来。
接下来的问题是:这套 3D Tiles 是怎么从 OSM 原始数据变出来的?
从 OSM 数据到 3D Tiles
数据从哪里来
本教程使用的原始数据https://www.geofabrik.de/data/download.html来自 (opens in a new tab) Geofabrik:https://www.geofabrik.de/data/download.html。 (opens in a new tab)
Geofabrik 是一个专门提供 OpenStreetMap 数据导出的服务,数据按国家和地区切分,可以免费下载。
北京区域的下载页面:Geofabrik Beijing:https://download.geofabrik.de/asia/china/beijing.html (opens in a new tab)

用 AI 生成转换脚本
完成这个转换任务,有不少现成工具可以用,比如 py3dtiles、osm2world、FME 等,各有侧重。但去评估和学习一套新工具本身就要花时间。这里选择了另一条路:直接让大语言模型来做。
整个过程分三步。
第一步:让 AI 分析数据
把 beijing-260327.osm.pbf 下载到目录后,先让 AI 分析这份数据的结构和内容。

AI 扫描出这份 PBF 文件包含五类 OSM 图层,其中 multipolygons 图层统计到建筑面约 131,046 个,并逐一列出了各建筑类型的数量分布——yes 类型最多有 88,903 个,apartments 有 14,929 个,以此类推。数据的边界范围、字段覆盖情况也都给出来了。有了这份分析,后续怎么处理心里就有数了。
第二步:问 AI 转换方案
接着追问:把这份数据转成 3D Tiles 用于 Cesium 加载,要怎么做?

AI 直接指出了这份数据的现实情况:13.1 万个建筑面,但真正带 height 字段的只有约 1733 个,带 building:levels 的约 1.9 万个。大部分建筑没有高度信息,因此不能直接生成高质量 3D Tiles,需要先做高度推断。
给出的方案是:提取建筑底面 → 推断高度 → 按网格切片 → 每个 tile 挤出 glb → 生成 tileset.json。并主动提出可以直接生成一套可运行脚本。
第三步:让 AI 生成脚本
确认方案后,给出具体要求:使用 Python 准备转换脚本,执行时需要显示进度。

AI 直接生成了完整的转换脚本,包含各阶段的进度输出。整个对话从数据分析到拿到可运行脚本,没有查任何工具文档。
脚本完整代码见仓库:osm_buildings_to_3dtiles.py (opens in a new tab),可以直接取用。
数据加工的五个步骤
整条转换链路可以拆成五步:
第一步:提取建筑面
原始 .osm.pbf 里有道路、水系、绿地等各种要素。脚本用 ogr2ogr(GDAL 的命令行工具)从中筛选出带 building 标签的多边形,输出为 GeoJSON 格式,存到 work/buildings.geojsons。
第二步:整理建筑属性
每个建筑要素保留核心字段:height、building:levels、building(建筑类型)等,过滤掉无效几何。
第三步:补全建筑高度
OSM 数据里的建筑并不都带高度信息——有的有精确的 height,有的只有楼层数,有的什么都没有。脚本采用分层策略补全:
- 优先使用
height字段(单位:米) - 没有
height就用building:levels × 3.2估算 - 两者都没有,按建筑类型给默认值(
apartments约 18m,commercial约 24m,house约 9m)
这是合理的近似,不是精确值,但对于体量白模来说足够用。
第四步:挤出三维几何
把每个建筑的二维底面多边形,沿垂直方向拉伸到对应高度,生成封闭的三维 mesh。
第五步:打包成 3D Tiles
把所有建筑按地理位置切分成若干空间格子,每个格子打包成一个 b3dm 文件,再生成统一的 tileset.json 入口。
切成多个 tile 而不是一个大文件,是 3D Tiles 格式的核心设计:相机只看到哪块区域,就只加载那块区域的数据,不需要把整个城市的几何都塞进内存。
环境准备
安装 GDAL
脚本的第一步(提取建筑面)依赖 ogr2ogr,它是 GDAL 的一部分。
macOS:
brew install gdal
ogr2ogr --version # 确认安装成功Linux(Ubuntu/Debian):
sudo apt-get install gdal-binWindows:建议通过 OSGeo4W (opens in a new tab) 安装。
准备 Python 环境
脚本需要 Python 3.9 及以上版本。在仓库根目录创建虚拟环境并安装依赖:
python3 -m venv .venvmacOS / Linux:
.venv/bin/python -m pip install -r data-pipeline/requirements.txtWindows:
.\.venv\Scripts\python -m pip install -r data-pipeline/requirements.txt依赖清单在 requirements.txt。
执行转换
环境准备好之后,运行:
macOS / Linux:
cd data-pipeline
../.venv/bin/python osm_buildings_to_3dtiles.py beijing-260327.osm.pbf --output tileset_output --work-dir work --overwriteWindows:
cd data-pipeline
..\.venv\Scripts\python osm_buildings_to_3dtiles.py beijing-260327.osm.pbf --output tileset_output --work-dir work --overwrite脚本会依次输出阶段进度:
Extracting building polygons...
Normalize...
Tiles...
Writing tileset manifest...跑完之后,tileset_output/ 里就是 Cesium 可以直接加载的数据。
转换结果
当前仓库包含的是北京 2026 年 3 月底快照的转换结果:
- 处理建筑要素:
131,046个 - 生成
b3dmtile:536个
详细摘要可以在 /tileset_output/summary.json:里查看。
第二部分小结
整条链路用一句话概括:
从 OSM 地图数据中提取建筑轮廓,补全高度,挤出为三维几何,切片打包成 Cesium 可加载的 3D Tiles。
三层关系:
输入层 → .osm.pbf(二维地图要素)
加工层 → 提取、补高、挤出、切片
输出层 → tileset.json + b3dm(三维瓦片数据)Cesium 只和输出层打交道,不关心加工过程。只要输出层格式正确,它就能显示。
后续可以怎么扩展
这条流程跑通之后,有三个方向可以继续:
优化视觉:按高度或建筑类型给白模着色,增加轮廓线,让体量层次更清晰。Cesium3DTileStyle 支持基于属性字段写条件表达式,可以实现相当灵活的样式控制。
优化性能:调整 tile 的切分粒度,降低每个 tile 的三角面数,或者引入 LOD(Level of Detail)让远景加载更粗糙的版本。
提升数据质量:OSM 的高度数据本身不完整,可以引入其他高度数据源(比如建筑数据库或遥感数据)做补全,或者保留更多建筑语义字段用于后续样式处理。