一个地球,36 座城市:Earth Online 从 0 到 1

Earth Online 首先是一个地球,不是一张世界地图。

它可以旋转、缩放、点击城市,进入城市后还能看到真实地图上的建筑、地标、天气和共享积分。城市不是一开始就有的,而是从 0 开始,一座一座长到 36 座。

这篇文章按“从零搭起来”的顺序,讲这个项目怎么从空白页面变成一个可交互的实时地球,以及中间做了哪些技术取舍。

1. 先做一个地球,而不是一张图

项目起点只有几个约束:

  • 静态托管,最好没有构建步骤;
  • 不用框架,原生 HTML/CSS/ES Module;
  • 不申请地图/地球的 API Key;
  • 手机和微信也要能打开。

所以地球用 Three.js 直接画:

  • 一个球体,贴 NASA 日景/夜景纹理;
  • 自定义 shader 按真实太阳位置混合日夜;
  • 叠加云层、星空、大气边缘光;
  • 城市之间用大圆弧连接;
  • 城市点不是 Sprite,而是 HTML 按钮,每帧从 3D 位置投影到屏幕。

日夜混合的核心是太阳方向和法线的点积:

1
2
3
float sun = dot(normal, sunDirection);
float day = smoothstep(-0.18, 0.22, sun);
vec3 surface = mix(nightColor, dayColor, day);

太阳方向由当前时间的 subsolar point 计算,不是固定光照。不同时间打开,晨昏线位置是对的。

纹理分两级:默认 2K WebP,桌面端相机拉近到 orbit.dist < 2.75 后异步加载 4K 日/夜 + 2K 云;移动端和微信保持 2K。这样首屏不会为了 4K 付出太大代价。

城市标签用 HTML 而不是 WebGL 字体:

1
2
3
4
5
6
const v = city.pos.clone().project(camera);
const facing = city.pos.dot(camDir) / 1.002;
const isFront = facing > 0.035 && v.z < 1;

el.style.transform =
`translate3d(${x - 15}px,${y - 15}px,0)`;

好处是标签始终清晰、可点击、可键盘聚焦;代价是每帧都要做投影和标签避让。

首次访问不请求定位权限,而是用浏览器时区推断大洲,把地球转到该大洲。隐私友好,代价是推断不精确。

2. 城市从 0 开始

一开始城市列表是空的。第一个城市加进来之后,整个产品才真正开始:

  • 地球上出现一个可点击的点;
  • 点击后相机飞向该城市,打开天气面板;
  • 面板显示当地时间、天气、空气质量、日出日落;
  • 分数 +5。

然后抽象出唯一的数据源:

1
2
3
4
const CITIES = [
{ id:'paris', name:{zh:'巴黎',en:'Paris'}, lat:48.8566, lon:2.3522, tz:'Europe/Paris' },
// ...
];

这一份数据同时驱动地球、积分榜、天气面板和城市详情:

1
2
3
4
5
CITIES
├── globe markers / camera
├── shared score board
├── weather + AQI panel
└── city detail (MapLibre + landmark layer)

城市从 0 加到 36,绝大多数工作只是加数据和详情配置,不需要改地球和榜单。这是项目能从 1 座城市顺利长到 36 座的关键。

天气和空气质量都来自 Open-Meteo,分开请求、10 分钟 sessionStorage 缓存,各自有失败状态。空气质量用 US AQI、PM2.5,和天气互不阻塞。

3. 城市详情:从程序化沙盘到真实地图

最早的详情页是程序化沙盘:盒子、圆柱、圆锥拼一个地标,加一些道路和树。它能跑,但很快遇到瓶颈:

  • 和真实城市无关;
  • 地标比例靠肉眼调;
  • 每加一座城市都要重新建模。

后来整个详情页换成 MapLibre + OpenFreeMap/OSM:

  • 真实建筑 footprint,用 fill-extrusion 按 render_height 拉伸;
  • Esri Dark Gray / Light Gray 作为无 Key 栅格兜底;
  • 倾斜、旋转的 district 视图;
  • 深海军蓝/浅纸色的 Orbital Blueprint HUD。
1
2
3
4
5
6
7
8
9
10
11
{
id: 'buildings-3d',
type: 'fill-extrusion',
source: 'omt',
'source-layer': 'building',
filter: ['!', ['has', 'hide_3d']],
paint: {
'fill-extrusion-height': ['coalesce', ['get', 'render_height'], 12],
'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0]
}
}

hide_3d 必须过滤:OSM 里主楼常被标成 hide_3d,真正渲染的是 building:part。

4. 36 座城市的地标系统

这是整个项目里技术密度最高的一块。

最初每座城市用 Three.js 拼一个程序化地标,转成青色线框叠在地图上。问题是:OSM 里本来就有帝国大厦、哈利法塔、台北 101 的体量,线框等于在真实建筑外面又套了一层简化模型,而且比例对不上。

4.1 先去 z14 矢量瓦片里核对数据

OpenFreeMap 的 planet 瓦片最大 z14,城市视图是 overzoom。用 queryRenderedFeatures 查地标会得到大量假阴性,因为瓦片没加载完、或者层级不对。

直接解瓦片后:

1
2
3
4
5
6
7
const tile = new VectorTile(new Pbf(buf));
const layer = tile.layers.building;
const f = layer.feature(i);

f.properties.render_height;
f.properties.render_min_height;
f.properties.hide_3d;

几个真实案例:

  • 埃菲尔铁塔:48×48m footprint,277/300/330m 三段 part,地图会拉成一根方柱;
  • 帝国大厦:主楼带 hide_3d,真正渲染的是 7 段收分 part;
  • 台北 101:508m part 存在,但相对标记点偏移 31–80m;
  • 罗马斗兽场:49m 环墙,不是三层拱廊。

结论是:

OSM 里有高度,不等于地图上能读出这个地标。

最终把 36 座城市分成三类:

模式 数量 适用条件
OSM 体量 + 瞄准框 32 OSM 体量能读出轮廓
地面街区高亮 2 线性/面状景区,或没有可用建筑体量
结构线框 2 完全无法用 OSM 表达形体

OSM + 瞄准框不画第二套建筑,只加地面测量环、收分引导棱线、高度尺、信标和 ALT。它明确是“标注”,不是“建筑”。

街区模式用于西安大唐不夜城和乌鲁木齐大巴扎:用 MapLibre 的 fill + 虚线 line 在地面画街区轮廓,不做 3D 锁。因为钟楼、城墙、大雁塔在 OSM 里没有可用 building 体量,硬套瞄准框只会悬空。

结构线框只剩拉萨布达拉宫和拉斯维加斯豪客摩天轮:前者 OSM 没有体量,后者不是建筑。

4.2 锁定框的实现

MapLibre 和 Three.js 共用同一个 WebGL 上下文,地标是一个 custom 3D 图层:

1
2
3
4
5
6
this.renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: gl,
antialias: true
});
this.renderer.autoClear = false;

经纬度对齐用 Mercator:

1
2
3
4
5
6
7
8
9
10
const origin = maplibregl.MercatorCoordinate.fromLngLat(active.origin, 0);
const scale = origin.meterInMercatorCoordinateUnits();

const s = scale * modelScale;
const sx = s * modelXZ;

const m = new THREE.Matrix4()
.makeTranslation(origin.x, origin.y, origin.z)
.multiply(new THREE.Matrix4().makeRotationX(Math.PI / 2))
.scale(new THREE.Vector3(sx, s, sx));

线框从 wireframe: true 改成 EdgesGeometry:

1
2
3
4
5
6
7
const lines = new THREE.LineSegments(
new THREE.EdgesGeometry(mesh.geometry, 12),
edgeMaterial
);
lines.renderOrder = 3;
mesh.add(lines);
mesh.material = ghostMaterial;

12° 阈值保留盒子的棱、圆柱的环和母线、球体的经纬线,去掉共面三角形的对角线;再叠一层半透明 ghost 体量,让线框有体积但不遮挡地图。

真实高度会暴露宽度问题:斗兽场 48m 高、模型只有 62m 宽,而真实足迹 188m。于是加了只拉伸 X/Z 的系数:斗兽场 3.0、王宫 2.4、MASP 12.0、布达拉宫 3.4、歌剧院 1.9。

5. 昼夜主题

按城市当地太阳高度切换两套调色:

  • 夜间:深海军蓝 + 青色线 + 琥珀标签;
  • 白天:浅纸色 + 中蓝楼体 + 深青线 + 同色琥珀强调。

不是换贴图,而是 CSS 变量 + MapLibre paint + Three.js 颜色同步:

1
2
3
4
5
6
cityPhaseDay = !!isDay;
BP_CYAN = cityPhaseDay ? 0x0b7188 : 0x39d0ff;
BP_AMBER = cityPhaseDay ? 0xb86a08 : 0xffb347;
BP_FILL = cityPhaseDay ? 0xd6ebf0 : 0x082432;

cityViewEl.classList.toggle('is-day', cityPhaseDay);

城市保持打开时每 5 分钟检查一次,日出日落会原地重绘,HUD 显示 DAY / NIGHT。

6. 没有账号的全局共享积分

规则很简单:聚焦 +5,进入详情 +10。难的是没有账号时怎么防刷和保证一致性。

客户端乐观更新,立即发送带 eventId 的事件;失败进入 localStorage 队列。服务端(NAS 上的 Next.js route handlers)用短期 session token、每个 token/城市/动作的冷却、IP 限流和幂等 eventId,分数持久化到 JSON 文件。

无账号 + 全局共享可行,代价是极快重复点击会被丢弃,以及需要维护一个 NAS 服务。

7. 声音与纹理

声音最终只有一首原创钢琴曲,全部 Web Audio 合成,没有音频文件、没有版权旋律。它从首页贯穿到城市视图,切换城市不换曲。微信 WebView 做 72Hz 高通,去掉低频嗡鸣。

之前做过多城市主题原型,后来移除了:维护 36 套主题复杂度高,城市间切换也容易突兀。单一音轨牺牲了“每城一景”,换来一致性和更少的代码。

纹理则是 2K/4K 分层:默认 2K,桌面拉近后 4K,移动端保持 2K。这个策略比“所有人 4K”更稳,尤其是微信里。

8. 约束、降级与测试

决策 收益 代价
无构建、无框架 部署简单、加载直接 单文件变大、状态管理靠自觉
OSM + Esri,无 Key 零配置、可公开部署 瓦片质量/可用性不可控
真实高度 和地图一致 低矮地标变小,需要调 zoom
OSM + 瞄准框 不重复建模 依赖 OSM 体量可读
街区模式 线性/面状地标不悬空 多一种呈现语言
单一音轨 一致、代码少 没有每城音乐
2K/4K 分层 移动端稳 桌面首屏不是最高清
无账号共享积分 低摩擦 需要冷却/IP 限流

已知限制:

  • 矢量瓦片加载不稳定时,瞄准框可能悬空;
  • OSM 高度标签质量参差,部分地标只有底座;
  • EdgesGeometry 只有 1px 线,没有 fat lines;
  • 街区多边形是手工近似,不是精确 OSM 地块;
  • 4K 纹理只适合桌面。

工程验证方面:

  • 26 个零依赖 Node 测试:助手命令解析 + 内嵌浏览器检测;
  • 视觉验证用 headless Chrome + CDP 的小工具批量截图,逐城检查;
  • MapLibre 失败时回退到旧的程序化 Three.js 街区;
  • 矢量瓦片失败时用 Esri 栅格底图,城市和标签仍可读;
  • ?proc=1 跳过纹理、?shot=1 截图模式、?textures=4k 强制高清、?audio-debug=1 音频诊断。

城市从 1 到 36 的过程中,最大的教训是:每加一座城市,都要回到真实数据里核对一次。配置里一个 540m 的坐标偏差、OSM 里一个 hide_3d,都会让画面看起来“差不多但不对”。

9. 从 0 到 36 的经验

  1. 先做一个地球,而不是一张图。 球体、太阳、纹理、标记,是这个产品的第一性。
  2. 城市用一份数据驱动。 加城市是加数据,不是加页面。
  3. 地图负责真实,Three.js 负责注释。 能用 OSM 体量就不要画第二套建筑。
  4. “有高度”不等于“能读”。 fill-extrusion 表达不了收分、镂空、拱廊。
  5. 真实比例会暴露宽度问题。 需要 X/Z 单独缩放,而不是继续放大整体。
  6. 线性/面状地标不要硬做 3D 锁。 街区高亮比悬空瞄准框诚实。
  7. 给降级留位置。 瓦片失败、OSM 缺失、音频被拦截,都要有退路。
  8. 无构建也能做出实时感。 静态托管 + NAS 计分,足够撑起一个共享产品。

最终,36 座城市里 32 座用 OSM 体量 + 瞄准框,2 座用街区模式,2 座保留结构线框。地球、城市、地标、昼夜、积分和声音,都是从 0 开始一层层加上去的。

这个项目最核心的工程原则,不是某个 shader 或某个几何算法,而是:

地图负责真实,注释负责表达;两者不要互相假装。

代码与项目说明