有人用 Three.js 把 14000 颗卫星的轨道在浏览器里全画出来了

SatDeck 是一个纯前端的卫星轨道可视化工具,用 Three.js 渲染 3D 地球和轨道路径,用 Leaflet 展示 2D 地面轨迹,数据来源是 CelesTrak 的公开 TLE 文件——里面有超过 14000 颗在轨卫星的轨道数据。选一颗卫星,3D 视图里轨迹线跟着画出来,2D 地图同步显示地面投影,还能输入自己的坐标计算该卫星当前的方位角和仰角。

Demo:satdeck.pages.dev GitHub:https://github.com/Boex9/SatDeck (opens in a new tab)

TLE 格式和解析

卫星轨道用的是 TLE(Two-Line Element) 标准格式,每颗卫星三行:

ISS (ZARYA)
1 25544U 98067A   25362.53357479  .00015441  00000+0  27889-3 0  9992
2 25544  51.6321  64.2558 0003250 314.4858  45.5864 15.49907133545304

第一行是名字,第二行第三行编码了六个轨道根数:半长轴、离心率、轨道倾角、升交点赤经、近地点幅角、平近点角。NORAD ID 在第二行第 2-7 位。

解析就是按三行分组,提取各字段:

function parseTLE(rawData) {
    const lines = rawData.split("\n").map(l => l.trim()).filter(l => l !== "");
    const satellites = [];
 
    for (let i = 0; i < lines.length; i += 3) {
        const name    = lines[i];
        const line1   = lines[i + 1];
        const line2   = lines[i + 2];
        const noradId = line1.slice(2, 7).trim(); // NORAD ID 在第 2-7 位
 
        if (!line1 || !line2) continue;
        satellites.push({ name, noradId, line1, line2 });
    }
    return satellites;
}

数据可以用本地 tledata.txt 文件,也可以直接拉 CelesTrak 的接口(https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=tle)。CelesTrak 有速率限制,生产环境建议把 TLE 文件托管在自己的后端,fetchtle() 只需改一个 URL。

SGP4:坐标计算的完整链路

有了 TLE,要知道卫星在某个时刻在哪里,需要用 SGP4 轨道传播模型(satellite.js 实现)。输出不是直接的经纬度,而是一个叫 ECI(地心惯性坐标系) 的三维坐标——以地球质心为原点、不随地球自转的参考系。

到最终渲染坐标,需要走完一条转换链路:

ECI 坐标 → GMST 修正地球自转 → 大地坐标(经纬度+高度)→ Three.js 场景坐标

用代码表示:

const satrec = satellite.twoline2satrec(line1, line2); // TLE → 轨道根数对象
const pv     = satellite.propagate(satrec, now);       // SGP4 计算 ECI 坐标
 
// GMST(格林尼治平恒星时)修正地球自转角度
const gmst = satellite.gstime(now);
const geo  = satellite.eciToGeodetic(pv.position, gmst);
 
const lat = satellite.degreesLat(geo.latitude);
const lon = satellite.degreesLong(geo.longitude);
const alt = geo.height; // km

之所以需要 GMST,是因为 ECI 坐标系不随地球转动,要把它换算到地面坐标,必须知道地球此刻转了多少角度。

最后一步是把大地坐标转换为 Three.js 的场景坐标,用球坐标系:

function LatLonAltToXYZ(lat, lon, alt) {
    const polarAngle     = THREE.MathUtils.degToRad(90 - lat);  // 极角 = 90° - 纬度
    const azimuthalAngle = THREE.MathUtils.degToRad(-lon);       // 方位角 = -经度
    const radius         = 1 + (alt / 6378);                     // 归一化,地球半径 = 1
 
    return new THREE.Vector3(
        radius * Math.sin(polarAngle) * Math.cos(azimuthalAngle),
        radius * Math.cos(polarAngle),
        radius * Math.sin(polarAngle) * Math.sin(azimuthalAngle)
    );
}

地球半径归一化为 1,高度相对地球半径(6378 km)缩放,低轨卫星大约在 1.1-1.2 左右。

轨道路径:每 30 秒采一个点

轨道路径就是在时间轴上批量采样,默认每 30 秒一个点,支持只看历史、只看预测、或前后都显示:

export async function calculateOrbitPath(
    noradId,
    durationMinutes = 200,
    stepSeconds = 30,
    mode = "both"   // "forward" / "backward" / "both"
) {
    for (let t = start; t <= end; t += stepSeconds) {
        const futureTime = new Date(now.getTime() + t * 1000);
        const pv   = satellite.propagate(satrec, futureTime);
        const gmst = satellite.gstime(futureTime);
        const geo  = satellite.eciToGeodetic(pv.position, gmst);
 
        segment.push(LatLonAltToXYZ(
            satellite.degreesLat(geo.latitude),
            satellite.degreesLong(geo.longitude),
            geo.height
        ));
    }
}

200 分钟 ÷ 30 秒 = 400 个采样点,连成 THREE.Line。轨迹每 2 秒重新计算一次,SGP4 每秒更新一次当前位置。

2D 地面轨迹还要处理一个边缘情况:日期线跨越——卫星从东经 179° 飞到西经 179°,经度跳变超过 180°,如果不做处理 Leaflet 会画出一条横跨地图的直线。解决方式是检测前后两点的经度差,超过 180° 就切断线段:

if (lastLon !== null && Math.abs(lon - lastLon) > 180) {
    segments.push(segment); // 当前段结束
    segment = [];            // 开新段
}

2D 地图:昼夜线和太阳位置

3D 视图配了一个 Leaflet 2D 地图,做地面轨迹补充显示。地图用深色底图(CartoDB dark),加了几个实用图层:

昼夜线:用 Leaflet Terminator 插件,每分钟更新一次,把地球当前被太阳照到的半球和夜晚半球的边界画出来。

太阳位置:根据当前时间计算太阳的地下点经纬度,用图标标在地图上。

用户位置:调用浏览器地理定位 API,把观测者位置标红点。如果用户拒绝授权,可以在输入框手动填经纬度。

观测参数:从你的位置看这颗卫星

如果输入了观测者坐标,卫星计算模块还会实时算出拓扑坐标——方位角、仰角、距离和径向速度:

// 观测者 ECEF 坐标
const obsEcf = satellite.geodeticToEcf(observer);
 
// 相对位置向量
const rho = {
    x: positionEcf.x - obsEcf.x,
    y: positionEcf.y - obsEcf.y,
    z: positionEcf.z - obsEcf.z
};
 
// 方位角和仰角
const look = satellite.ecfToLookAngles(observer, positionEcf);
// look.azimuth(方位角)/ look.elevation(仰角)/ look.rangeSat(距离 km)

仰角 > 0° 说明卫星在地平线以上,这时候理论上是可见的(不过还要考虑光照条件和大气散射)。同时还会显示径向速度(正值为远离,负值为靠近)——这个数据用来理解多普勒频移,HAM 无线电爱好者追踪卫星信号时会用到。

地球用了自定义 Shader:Cubemap 盒子投影

地球渲染用的不是标准材质,而是自定义 ShaderMaterial,原因是普通球面 UV 贴图在极点附近会严重拉伸。解决方案是把地球纹理转成 6 面 Cubemap,用盒子投影采样,极点完全不拉伸:

// 顶点着色器:earthRot 控制地球自转,vDir 传给片元着色器
uniform mat3 earthRot;
varying vec3 vDir;
 
void main() {
    vec3 worldDir = (modelMatrix * vec4(position, 1.0)).xyz;
    vDir = normalize(earthRot * worldDir);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
 
// 片元着色器:Cubemap 采样 + sRGB 伽马校正
uniform samplerCube cubeMap;
varying vec3 vDir;
 
void main() {
    vec3 col = textureCube(cubeMap, normalize(vDir)).rgb;
    gl_FragColor = vec4(pow(col, vec3(1.0 / 2.2)), 1.0); // gamma 校正
}

地球纹理推荐 NASA Blue Marble 系列,先用全景转 Cubemap 工具生成 6 张面图。地球自转通过每帧更新 earthRot 矩阵实现,与 GMST 时间同步,确保卫星位置和地球方位保持一致。

跑起来

git clone https://github.com/Boex9/SatDeck.git
cd SatDeck
python -m http.server
# 打开 http://localhost:8000

不能直接双击 HTML 文件,file:// 协议会阻止 fetch() 加载本地文件。Backend/tledata.txt 里预置了 ISS 等卫星,换成从 CelesTrak 下载的完整文件就能看到 14000 颗的轨道。

写在最后

SatDeck 技术上几个值得记住的点:TLE → SGP4 → ECI → GMST 修正 → 大地坐标 → Three.js 坐标的完整链路;Cubemap 盒子投影解决极点拉伸;日期线跨越的分段处理。这套管线在任何需要精确地球位置可视化的场景都可以直接参考。