不用地图 SDK,不用任何外部 3D 模型:街区来自 OpenStreetMap 的真实数据,12 个景点全部用代码"盖"出来,镜头自动巡览,到站后还有小人在景点里走动。这篇文章聊聊它是怎么做出来的。

一、先看效果
这是一块 1920×1080 的数据大屏,主体是一座可旋转、可缩放的成都市中心 3D 城市,风格是明亮的"微缩插画风"。功能如下:
| 模块 | 说明 |
|---|---|
| 真实街区还原 | 1.9 万栋楼、2500 多条道路、锦江与府南河、公园绿地,全部来自 OpenStreetMap |
| 12 个景点精细建模 | 天府广场、太古里、IFS、宽窄巷子、人民公园、文殊院、合江亭·安顺廊桥、望江楼、熊猫塔、武侯祠·锦里、杜甫草堂、熊猫基地 |
| 自动巡览 | 镜头依次飞到各景点,停留后继续;拖动、滚轮缩放后 15 秒无操作自动恢复 |
| 到站人流 | 镜头到站后,景点的步行路径上出现低多边形小人来回走动,离站时淡出 |
| 信息面板 | 城市指标、热门景点客流、景点介绍(含实景图),点击导航栏可直达任一景点 |
| 交互 | 拖动旋转、滚轮缩放、点击楼栋查看信息、复位、全屏、罗盘与比例尺 |
几个景点到站时的画面:






二、技术选型与整体结构
- Vue 3 + Vite :只负责大屏外壳(标题、面板、导航栏),尺寸写 px,构建时由
postcss-pxtorem转成 rem 适配不同屏幕。 - three.js 0.186:城市、景点、镜头、阴影、标签、小人全部在这里。
- Python 预处理脚本:从 OpenStreetMap 拉数据,离线处理成一份静态 JSON,页面运行时不依赖任何在线地图服务。
text
scripts/fetch-osm-city.py # 拉 OSM 数据 → public/city/chengdu.json
src/views/city/
├── index.vue # 大屏外壳
├── components/ # 标题、总览、客流、景点介绍、导航栏、罗盘......
├── data/cityData.js # 业务数据:指标、景点坐标、介绍、机位
└── scene/
├── CityScene.js # 场景总装:渲染器、灯光、各图层、巡览、交互
├── terrain.js / roads.js # 地面、绿地、水面、道路
├── buildings.js # 楼栋挤出合并 + 窗格着色器
├── trees.js # 行道树、公园树(InstancedMesh)
├── cameraTour.js # 镜头巡览
├── markers.js # 定位针与标签(CSS2D)
├── shadow.js # 静态阴影
├── crowd.js # 到站人流
└── landmarks/ # 12 个景点 + 程序化建模构件库 kit/
三、数据:把 OpenStreetMap 变成一份 JSON
很多人以为 OpenStreetMap 只是一张底图。其实它是一个开放的矢量地理数据库 :网站上看到的地图只是它渲染出来的瓦片图片,而每栋楼在数据库里都是一条带 building 标签的记录,存着轮廓上每个点的经纬度,有的还带名称、层数、高度。
用 Overpass API 按范围就能把一片区域的楼栋全拉下来:
text
[out:json][timeout:120];
(way["building"](30.624,104.012,30.686,104.098););
out geom;
拿到数据后,脚本做了几件事:
1. 经纬度换成米。 three.js 里用米做单位最自然。以天府广场为原点,用等距圆柱投影把经纬度换成局部坐标(X 向东、Z 向南),城市尺度下误差可以忽略。前端用同一套公式,景点坐标和几何数据才能对齐:
js
export function createProjection([lon0, lat0]) {
const kx = 111320 * Math.cos((lat0 * Math.PI) / 180) // 1° 经度对应的米数
const kz = 110540 // 1° 纬度对应的米数
return {
toLocal: (lon, lat) => [(lon - lon0) * kx, -(lat - lat0) * kz]
}
}
2. 估算楼高。 这是 OSM 数据最大的短板。主城区 18750 栋楼里,带实测高度的只有 113 栋,带层数的 810 栋,约 95% 的楼既没有高度也没有层数。策略是:有高度用高度,有层数按 3.3 m/层,都没有就按建筑类型在一个区间里取值,并且用楼的 OSM 编号做随机种子,保证每次生成结果一样:
python
def estimate_height(tags, osm_id):
h = parse_num(tags.get("height"))
if h:
return max(3, round(h, 1))
levels = parse_num(tags.get("building:levels"))
if levels:
return max(3, round(levels * 3.3, 1))
lo, hi = TYPE_HEIGHT.get(tags.get("building", "yes"), (9, 30))
r = random.Random(osm_id) # 用 OSM 编号做种子,结果可复现
return max(3, round(lo + (hi - lo) * r.random() ** 1.6, 1))
3. 其他清洗。 大面积水体、公园在 OSM 里常被拆成多段,要按端点拼回闭合多边形;道路、河流裁到地图范围内;离主城约 5 km 的熊猫基地单独拉一小块"飞地"数据。最终产物是一份 2.7 MB 左右的 chengdu.json,一栋楼长这样:
js
{ p: [[2862.3, -2125.0], [2869.7, -2103.4], ...], h: 6.0, n: "电子信息产业大厦" }
四、城市底图:两万栋楼怎么画得动
1. 楼栋:挤出 + 合并成一个 Mesh
每栋楼用 ExtrudeGeometry 按轮廓挤出高度,然后用 mergeGeometries 把全城的楼合并成一个 Mesh。1.9 万栋楼只占 1 次 draw call,楼高分档的颜色写进顶点色。
2. 窗格:着色器里"画"出来
窗户不额外加面,而是在 onBeforeCompile 里给标准材质注入一段片元着色器:按墙面切线方向和世界高度取格子,格子里的像素调暗或调亮。远处一个像素会跨过好几个窗格,硬边会闪成摩尔纹,所以用 fwidth 把远处的窗格淡出成平均亮度:
glsl
if (vWN.y < 0.5) { // 只画侧面,屋顶不画
vec2 t = normalize(vec2(-vWN.z, vWN.x)); // 墙面切线
vec2 g = vec2(dot(vWPos.xz, t) / uWinStep.x, vWPos.y / uWinStep.y);
vec2 fw = fwidth(g);
float win = step(uWinGap.y, fract(g.y)) * step(uWinGap.x, fract(g.x));
float mean = (1.0 - uWinGap.x) * (1.0 - uWinGap.y);
win = mix(win, mean, smoothstep(0.25, 0.5, max(fw.x, fw.y))); // 远处淡出,防闪烁
diffuseColor.rgb = mix(diffuseColor.rgb, diffuseColor.rgb * vec3(0.72, 0.78, 0.88), win * 0.55);
}
3. 地面分层与树
地面、绿地、水面、道路按固定高度叠放(0.2 m、0.3 m、0.6 m......),再用 polygonOffset 固定前后顺序,远看不会互相"打架"。几千棵树用 InstancedMesh 一次画完,每片绿地用自己几何形状算出的随机种子撒点,以后增删数据不会把全城的树都打乱。
五、景点:用代码"盖房子"
这是整个项目花时间最多的部分。12 个景点没有用任何外部模型,全部是程序化建模,依据是实景照片加 OSM 的真实轮廓和朝向。

1. 构件库 + 顶点色合批
landmarks/kit/ 是一套构件库:台基、柱列、栏杆、墙体,歇山 / 攒尖 / 悬山屋顶,亭、塔、民居、竹、树......每个构件都是一个普通的 BufferGeometry。
关键是 ColorBuilder:所有构件先按颜色写好顶点色,再烘焙合并成一个几何体,一个景点最后只有 1~2 个 Mesh。下面是简化后的用法:
js
const b = new ColorBuilder()
b.add(platformGeometry, "#B9B6AE", frame(cx, 0, cz, bearing)) // 台基
b.add(columnsGeometry, "#B8352B", local(f, 0, 1.2, 0)) // 红柱
b.add(hipRoof(w, d, 3.2, opts), "#4A5361", local(f, 0, 7, 0)) // 歇山顶
const mesh = new Mesh(b.bake(), landmarkMaterial()) // 合并成一个 Mesh
2. 统一的景点契约
每个景点是一个模块,导出 build(ctx),返回统一的结构:
js
export function build({ project, buildings, theme, spot }) {
return {
meshes, // 模型(1~2 个 Mesh)
zones, // 替换区:区域内 OSM 自动生成的"方盒楼"会被隐藏,换成精细模型
markerHeight, // 定位针高度
walkways // 小人的步行路径
}
}
3. 照着照片还原,再做"插画式放大"
真实建筑在 300 m 外的镜头里往往只有几十个像素,所以会按插画的方式适当放大,但形制必须真实:
- 安顺廊桥:三孔石拱 + 两个圆形泄洪孔,橙金琉璃瓦、红柱、米白花格窗,桥墩前立着镇水兽;
- 望江楼崇丽阁:下两层四方、上两层八角,绿琉璃瓦、鎏金宝顶,整组文物区放大 1.6 倍;
- 武侯祠:中轴"前高后低",惠陵圆冢加一道 S 形"红墙夹道",锦里古街用程序排布川西民居;
- 熊猫塔:OSM 里有按施工图画的分件数据,悬盘和塔楼两只"盖碗"按真实高度还原。


六、镜头巡览:球坐标插值
镜头飞行不是在直线上插值位置,而是对**注视点 + 球坐标(方位、俯仰、距离)**分别插值,配合 easeInOutCubic 缓动,转弯更自然。方位角会归一化到 -π, π 走最短弧,转角超过 90° 时自动放慢,跨区域长距离飞行(比如飞往熊猫基地)还会在半程把镜头拉高:
js
update(dt) {
if (!this.flying) return
this.flyProgress += dt / this.flyDuration
const k = Math.min(1, this.flyProgress)
const e = easeInOutCubic(k)
this.target.lerpVectors(from.target, to.target, e)
this.spherical.theta = from.s.theta + this.flyDTheta * e // 最短弧
this.spherical.phi = lerp(from.s.phi, to.s.phi, e)
this.spherical.radius =
lerp(from.s.radius, to.s.radius, e) + this.flyHop * Math.sin(Math.PI * e) // 半程抬高
this.apply()
if (k >= 1) this._arrive() // 到站:收紧阴影、放出人群
}
七、定位针、标签与阴影
标签 用 CSS2DRenderer 把 DOM 元素挂在三维坐标上,文字清晰、样式好写。为了不压住页面顶部的标题,每帧检查标签位置:靠近顶部时当前站的标签缩短引线往下压,其他标签淡出。
阴影 用的是静态阴影贴图:renderer.shadowMap.autoUpdate = false,只在切换场景时置一次 needsUpdate。总览时阴影覆盖整城;到站后把阴影相机收紧到站点周围 ±1000 m,同样一张阴影贴图,细节清楚很多。整城阴影的范围不是写死的,而是在城市建好后按楼栋、树冠、景点的真实包围和影子落点算出来的。
八、到站人流:几百个小人怎么走
每个景点返回的 walkways 是一组折线,描述小人可以走的路面:
js
{ points: [[x, z], ...], y: 1.0, width: 2.4, closed: false, density: 1.5 }
到站时按路径长度和密度生成小人,全城共用 5 个 InstancedMesh(身体、头、头发、左腿、右腿),每帧只改实例矩阵。走路的感觉来自三个小细节:两条腿相位差 π 前后摆动,身体随步伐上下起伏,走到路径尽头平滑转身折返:
js
const swing = SWING * Math.sin(phase) // 左腿 +swing,右腿 -swing
const bob = BOB * h * (Math.abs(Math.cos(phase)) - 1) // 每迈一步身体起伏一次
小人被放大到 4 m 高:真人 1.7 m 在 600 m 外只有四五个像素,看不出是人;4 m 时约 10 个像素,头、身、腿能分开。
九、踩过的坑
- 远景闪烁(深度冲突) :相机近裁剪面从 1 改成 20 m,深度精度提高了一个数量级;层叠的地面、屋面统一用
polygonOffset或拉开高度差。廊桥屋面原来是"橙瓦 + 红色檐下"两层,远看红层会透上来,改成先画红层再画瓦面就好了。 - 阴影漏光:阴影贴图按材质的 side 绘制,悬在空中的平台如果只有朝上的面,在阴影里就是"透明"的。空中的水平构件都要补朝下的面。
- 数据一变,全城都变:最早撒树和景点里的随机数是按数组下标播种的,地图扩一次范围,所有树和民居都会重新随机。后来统一改成按几何形状哈希播种,增删数据只影响变动的部分。
- 窗格摩尔纹 :远处窗格高频闪烁,用
fwidth按屏幕导数淡出解决。 - OSM 数据不全:楼高 95% 靠估算;景点内部的溪水、园路、花径走向很多没有画,只能按导览图和照片推定,并在文档里标明。
十、工程化:怎么保证改不坏
- 单景点预览页 :
/city-lab.html?landmark=dufu&yaw=200&pitch=40&dist=120,只加载一个景点,可以单独调角度、距离、阴影和人流,不进生产构建。 - Node 校验脚本:在浏览器外构建景点,输出三角形数和几何哈希,重构前后哈希一致就说明几何没变;另一项检查沿每条步行路径每 0.5 m 采样,确认小人脚下有路面、头顶 4.35 m 内不碰屋檐、腿 / 身体 / 头离墙都有足够距离。
- 无头 Chrome 截图:每个景点改完都和实景照片逐张对照,确认形制、配色、比例。
十一、一些数字
| 指标 | 数值 |
|---|---|
| OSM 楼栋 / 道路 / 水面 / 绿地 | 19083 / 2559 / 100 / 196 |
| 景点数 | 12(每个 1~2 个 Mesh) |
| 景点三角形合计 | 约 32 万 |
| 最大单个景点 | 武侯祠·锦里,约 4 万三角形 |
| 每站人流 | 46~90 人 |
| 外部 3D 模型 / 贴图 | 0 |
写在最后
这个项目最大的感受是:开放数据 + 程序化建模的组合,能用很低的素材成本做出"像真的"城市。OSM 给了准确的街区格局,照片给了建筑的形制,剩下的就是把它们翻译成代码。
后续还想做的:给重点楼栋加一张楼高修正表、夜景灯光模式、把景点构件库整理成独立的包。
地图数据 © OpenStreetMap contributors,遵循 ODbL 协议。