只用 three.js + OpenStreetMap,手搓一个「成都城市 3D」数据大屏

不用地图 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. 远景闪烁(深度冲突) :相机近裁剪面从 1 改成 20 m,深度精度提高了一个数量级;层叠的地面、屋面统一用 polygonOffset 或拉开高度差。廊桥屋面原来是"橙瓦 + 红色檐下"两层,远看红层会透上来,改成先画红层再画瓦面就好了。
  2. 阴影漏光:阴影贴图按材质的 side 绘制,悬在空中的平台如果只有朝上的面,在阴影里就是"透明"的。空中的水平构件都要补朝下的面。
  3. 数据一变,全城都变:最早撒树和景点里的随机数是按数组下标播种的,地图扩一次范围,所有树和民居都会重新随机。后来统一改成按几何形状哈希播种,增删数据只影响变动的部分。
  4. 窗格摩尔纹 :远处窗格高频闪烁,用 fwidth 按屏幕导数淡出解决。
  5. 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 协议。

相关推荐
星若生辉1 小时前
Hugo 静态站点搭建|从本地调试到上线部署实操指南
前端
沐浴露z1 小时前
Agent 响应延迟过高?从工具治理角度提供两条思路
前端·网络
CappuccinoRose1 小时前
FormData数据处理
开发语言·前端·javascript·表单数据
飘尘2 小时前
SVG和Canvas,前端里的两支“画笔”,用的时候怎么选择?
前端·javascript·面试
计算机魔术师3 小时前
DeepSeek 据报道接近完成至少 800 亿元融资,腾讯与宁德时代参与
前端
可乐ea4 小时前
多模型编排的三层:框架、模型路由与提供商路由
前端·网络·人工智能·ai智能体·多智能体协作·多模型编排·大模型路由
青柠之夏cc5 小时前
前端拖拽功能原生实现,不引入拖拽库完成业务
开发语言·前端·python
liangshanbo12156 小时前
前端大文件切片上传怎么做?
前端
Sand(ContextGate)6 小时前
Python Agent 测试实战:测试与评估,让 Agent 像传统软件一样可交付
前端·javascript·python·microsoft·ai