Apollo-11 在我这儿的身份不是飞船,而是一个练手用的小项目代号:把人类第一次登月任务的完整过程,做成一页滚动驱动的沉浸式Web可视化。项目做完大概两周,代码量不大,但中间踩的坑不少。如果你也想用真实数据做点什么带叙事感的网页,或者正在学ScrollTrigger、Three.js这两个库,这篇应该能省你不少时间。
先说清楚这项目能干嘛:用户打开页面往下滚动,随着滚动进度会依次经历"发射准备—升空—入轨—地月转移—绕月—登月—月面活动—返航—溅落"几个阶段;每个阶段有一张事件卡片、一到两个可读性很强的参数大数字,旁边还有一个持续演变的3D月地场景。它解决的核心问题是:怎么让一段线性历史变成用户自己掌控节奏的浏览体验,而不是一镜到底的片子或者干巴巴的图配文。
我下面不准备贴一堆成品代码,而是按项目推进顺序讲:先从立项和叙事结构说起,接着是数据清洗、滚动驱动、3D场景、移动端和低端机适配,最后是复盘。每一步都尽量说明为什么这么做,以及我实测时踩到的具体问题。
1. 立项不冲动:把Apollo-11的叙事边界和信息架构定死
做这类项目,最怕的不是技术实现,而是页面做完了自己都不知道要讲什么。我见过不少可视化练手作品,特效堆得特别满:粒子、3D、图表、滚动动画全塞进去,结果用户滚到一半就失去耐心。原因通常不是动画不够炫,而是叙事线索被视觉元素淹没了。
所以我在动手前给自己立了三条规矩。第一,一条主线到底,整个过程只讲"人类怎么从地球到月球再回来"这一件事。第二,每个阶段必须有明确的"这一屏想让用户记住什么",不贪多。第三,把表现层和内容层分开:文字负责叙事,数字负责佐证,画面负责氛围,三者各司其职,而不是让3D场景什么都演。
1.1 为什么偏偏选登月任务
选登月任务当题材有几个我比较看重的点,放在别的题材上未必成立。
一是认知成本低。几乎所有人都知道"登月"这件事,但绝大多数人对过程只有模糊印象。这意味着我可以把"事件"包装成"故事"来呈现,用户不需要前置知识,光靠滚动和卡片就能重新理解这段历史。做可视化最怕选一个用户完全陌生的领域,那样你得先花大量篇幅解释背景,主题本身的内容反而排不上号。
二是数据形态丰富。这个任务天然有多个阶段,每个阶段都有可量化指标,比如起飞推力、轨道高度、飞行速度、月面停留时长。这些数字放上页面就是天然的"记忆锚点",不需要我额外编造内容。对练手项目来说,有真实结构化数据可做清洗和建模,比虚构数据要扎实得多。
三是视觉调性统一。深空背景天然是暗色的,暗色下做发光线条、粒子星点和卡片过渡都不容易翻车,比白底页面省很多配色精力。而且月亮这个元素在视觉上辨识度极高,不需要做很精细的模型,用户一眼就能认出来。
1.2 信息架构:三层拆分
我把整个项目的信息拆成三层:主线阶段、核心事件、参数指标。
主线阶段,就是上面提到的那九个阶段。它们按时间顺序排列,每个阶段对应页面的一段滚动区间。核心事件,是每个阶段里最值得讲的那个瞬间,比如"发动机点火"或"着陆前动力下降开始"。参数指标,则是这个事件的一两个数字佐证,比如推重比、燃料余量、速度。
这样拆分的原因很实际:用户短时间能消化的信息有限。一个阶段卡片上如果写了五个事件、三组数据,滚动节奏肯定乱。信息架构的产出是一份Markdown文档,里面按阶段写好:阶段名、一句"高亮文案"、2到3个参数、以及一个供开发使用的ID。后续所有前端代码都以这份文档为先导,数据清洗、动画配置、文案排版都拿它对齐。
1.3 阶段与表现层的对应关系
定好信息架构后,我拉出一张表,确定每个阶段长什么样。表现层分三类:文字卡片、参数图表、3D场景镜头。不是每个阶段都要三者齐发,而是按叙事需要轮换。
比如发射段,用户情绪刚被调动起来,用整页的倒计时数字加火箭升空镜头,效果远好于堆一堆图表;地月转移段则相对安静,用轨迹线和距离大数字让用户感知"渐行渐远";到了绕月、登月段,再把画面拉近到月面,配合参数卡片制造节奏起伏。
这张表最终的形态大概是这样的:
| 阶段 | 核心事件 | 关键参数 | 表现层重点 |
|---|---|---|---|
| 发射准备 | 倒计时归零 | 总推力约3400吨 | 全屏数字加氛围音 |
| 升空入轨 | 火箭逐级分离 | 近地轨道高度约190公里 | 3D镜头跟随 |
| 地月转移 | 轨道修正点火 | 地月距离约38万公里 | 轨迹线加距离数字 |
| 绕月 | 进入环月轨道 | 轨道速度参考值 | 3D月球特写 |
| 登月着陆 | 动力下降开始 | 着陆区上方剩余高度 | 3D镜头配卡片 |
| 月面活动 | 舱外活动开始 | 表面停留时长 | 文字叙事加场景光照 |
| 返航对接 | 上升级点火 | 交会对接用时 | 3D轨迹加参数 |
| 再入溅落 | 返回舱再入 | 海上溅落区 | 全屏结尾卡 |
表格不是最终视觉稿,但它能让我在写代码前就明确:哪几个阶段需要3D镜头、哪几个阶段靠数据和文案撑场。这避免了"每页都做一个3D镜头"的重复劳动。
2. 任务数据打底:从接口抓取到本地JSON的清洗全过程
信息架构案定完之后,第二件事是搞数据。我原来以为最省事,实际上花的时间比预想多出一倍。登月任务的数据散落在各种地方,字段口径五花八门,最大的坑是时区和单位。
2.1 数据源选型:接口、社区包还是人工整理
我当时认真比过三种来源。
第一种是公开的航天任务数据接口。优点是真实、字段结构统一,有任务时间线的JSON返回;缺点是很"工程化",字段名全是缩写,事件描述基本就是一行日志,直接拿来当叙事文案根本没法看。
第二种是社区里有人整理好的任务数据包。这类包一般按events数组组织,已经把事件名翻译成易读文本,适合快速起步;但覆盖范围不完整,而且我发现好几个数据包的时间字段用的是发射场当地时间,跟UTC混在一起,需要二次校验。
第三种就是人工从百科页面整理。这个最自由,事件描述可以按我的叙事逻辑重写,但整理十几个阶段、几十个事件的文本相当耗时,而且特别容易笔误。
我的方案是一加三:以接口数据为主干,保证时间线完整性和时间戳准确;用人工整理补充事件的自然语言描述;再用一个校验脚本管住两个来源合并后的质量问题。不要迷信某一种数据源,数据源混合使用才是常态。
2.2 清洗与结构化的具体思路
拿到原始数据后,第一件事是统一结构。我定义了一套规则:所有时间戳转成UTC的ISO8601字符串;数值字段跟单位分离,数值单独存成number,单位也单独存成string;每个事件必须能映射到信息架构里九个阶段之一,映射不了就归到"其他",后续人工处理。
这个映射我用了一个关键词规则脚本,按事件标题里的关键词判断阶段。当时写的片段大概是这样的:
const STAGE_KEYWORDS = [ { stage: 'launch', keywords: ['liftoff', 'ignition', 'launch'] }, { stage: 'orbit', keywords: ['orbit insertion', 'circularization'] }, { stage: 'tdi', keywords: ['trans-lunar', 'mid-course', 'lunar transfer'] }, { stage: 'lunar_orbit', keywords: ['lunar orbit insertion', 'LOI'] }, { stage: 'landing', keywords: ['powered descent', 'touchdown', 'landing'] }, { stage: 'surface', keywords: ['surface', 'EVA', 'sampling'] }, { stage: 'rendezvous', keywords: ['ascent', 'rendezvous', 'docking'] }, { stage: 'reentry', keywords: ['reentry', 'splashdown', 'parachute'] } ]; function assignStage(rawEvent) { const title = rawEvent.title.toLowerCase(); for (const rule of STAGE_KEYWORDS) { if (rule.keywords.some((kw) => title.includes(kw))) { return rule.stage; } } return 'other'; }这套规则看着简单,实际运行后发现了两个问题。第一,关键词之间会有重叠,比如"lunar orbit insertion"同时包含"lunar"和"orbit",如果两个规则都匹配,就得靠优先级顺序,而我的数组顺序恰好解决了这一点,先匹配"lunar_orbit"再匹配"orbit"。第二个问题是缩写,比如"LOI"在不同数据源里大小写不一致,甚至还有"L.O.I."这种带点的写法。所以映射前我还加了一步:把所有标题统一成小写,并先剥离点号再匹配。
2.3 校验脚本与第一次跑出来的错误清单
阶段映射完成之后,我写了一个非常简单的Node校验脚本,主要检查四类问题:必填字段缺失、时间戳格式错误、时间线逆序、阶段数量异常。代码短到不值得贴,但逻辑就几句话:
const assert = require('assert'); for (const ev of events) { assert(typeof ev.title === 'string' && ev.title.length > 0, '事件标题缺失'); assert(Date.parse(ev.timestamp), `时间戳格式非法: ${ev.title}`); } for (let i = 1; i < events.length; i++) { assert( Date.parse(events[i].timestamp) >= Date.parse(events[i - 1].timestamp), `时间线逆序: ${events[i - 1].title} -> ${events[i].title}` ); }实际跑出来的结果,第一次就抓到了六个错误。这里说三个有代表性的。一个事件的时间戳写了本地时区,跟前后UTC时间相比差了八个小时;还有一个"动力下降开始"被关键词规则误分进"绕月"阶段,因为标题里同时带了"engine"和"lunar"两个词;最后一个是"溅落"事件的数值单位写成了英里,而我统一用公里。这些问题如果直接上线,用户要么看到时间倒流,要么看到一个阶段卡片的数字明显不匹配。
清洗数据这件事没有什么魔法技巧,核心就是:规则要简单、校验要自动化、错误要可读。宁可多花半小时写校验脚本,也别让脏数据流进页面组件。
3. 滚动叙事时间轴:GSAP ScrollTrigger的驱动模型与卡点调试
数据准备好之后,真正开始写前端交互,第一仗就是滚动叙事时间轴。这个项目的骨架是"滚动驱动一切",不是"点击切换",所以滚动系统的稳定性决定了整个项目的体验。
3.1 为什么不用原生scroll监听
可能有人觉得,滚动动画直接监听window scroll,算一下scrollTop和元素偏移量不就行了?确实能行,但工程上会很快失控:每帧要手动测一堆元素位置、判断是否在视口内、再给不同DOM节点塞样式。一旦阶段卡片变多、3D场景介入,手写代码的维护成本会直线上升。
我选择的是GSAP的ScrollTrigger插件。它帮我处理了三个最关键的问题:滚动进度的归一化,也就是给出一个稳定的0到1之间的progress值;元素固定效果的自动计算,也就是pin;以及scrub机制,也就是滚动位置和动画进度之间的平滑映射。这三个点正是滚动叙事最麻烦的部分。而且GSAP的动画是用tween的方式声明式配置,不会直接改CSS class,跟React、Vue这类框架配合起来也更顺。
3.2 滚轮驱动的整体实现骨架
项目的整体驱动逻辑是:页面划分成若干个stage section,每个section的高度都大于等于视口高度。用户的滚动动作落在某个section内部时,ScrollTrigger会把这个section内部的滚动进度映射为0到1,然后我们拿这个进度去驱动不同的表现层:卡片进出场、参数数字滚动、3D场景镜头移动。
实现骨架大概是这样:
const sections = gsap.utils.toArray('.stage-section'); sections.forEach((section, i) => { const state = { progress: 0 }; ScrollTrigger.create({ trigger: section, start: 'top top', end: 'bottom bottom', scrub: true, onUpdate: (self) => { state.progress = self.progress; // 这一步把进度交给场景控制器、卡片控制器等 onProgressChange(i, state.progress); } }); });我特别想强调scrub: true这个配置。没有它,onUpdate里的progress值会随着滚动跳变,画面会像帧率不足的翻页效果;有了它,GSAP会做一次插值平滑,让浏览器每一帧拿到的progress值都是连续的、可预期的。这个细节决定了滚动叙事和普通滚动监听之间的最大差异。
3.3 两个容易让人放弃的坑:pin长度和滤镜卡顿
做滚动叙事,一定会踩到两个坑,我先说结论。
第一个坑是pin长度。ScrollTrigger的pin会在滚动过程中把某个元素固定在视口内,并自动在容器末尾补白,让后续内容能正常滚上来。但如果补白高度太大,用户会感觉"我已经滚了很久,怎么界面还停在这一屏",特别容易误以为页面卡死。我最后把每个阶段pin的补白时间控制在视口高度的1.2倍以内,宁可让卡片切换紧凑,不能拖沓。
第二个坑是CSS滤镜。第一次上线前测试,我在阶段卡片上用了filter: blur(8px)做进场模糊效果,结果在普通笔记本上连续滚动时掉帧非常明显。原因是filter会强制浏览器做额外的像素级合成计算,在滚动驱动的持续动画中代价很大。换掉后改成opacity加scale的组合,视觉上几乎看不出差别,性能提升却非常直观。
还有一个细节是关于数字滚动组件。为了不让参数大数字滚动的算法成为性能瓶颈,我坚持只更新textContent,不更新innerHTML,避免触发子树重新解析;数字较小的时候不做toLocaleString格式化,防止小数点或逗号造成宽度抖动。
4. Three.js月球场景从零搭建:天体材质、轨迹动画与光照的取舍
页面右侧的3D场景是这个项目视觉上最出彩的部分,也是工作量最容易失控的地方。我的策略一直是"克制":场景里只放必须的东西,其余全部砍掉。
4.1 不堆素材:场景里只放四样东西
我的3D场景最终只有四样东西:一个带简单凹凸质感的月球、一片粒子星空、一条表示任务轨迹的发光曲线、以及一个始终朝向前方运动轨迹的相机。
月球直接用SphereGeometry,半径设为5,材质用MeshStandardMaterial。贴图怎么来的?我借助外部工具生成了一张512像素的程序噪声凹凸纹理,并没有去下载物理月面贴图。原因很实际:物理贴图动辄几兆,加载成本高;程序噪声在暗色背景下已经足以让人识别出"这是月面"的粗糙感,配上台词文案,用户不会觉得缺细节。
星空用THREE.Points和BufferGeometry生成,粒子均匀分布在球面上。粒子数量我控制在2000个左右,再多在低端机上就会被明显感知。大家总喜欢把粒子堆到上万颗,实际上深色背景下的星空效果,两三千颗完全够用。
4.2 轨迹曲线:从真实数据抽稀到CatmullRomCurve3
轨迹动画需要一条从地球附近延伸到月球附近的曲线。这里有一个挺容易忽略的工程点:原始轨道数据点太密了,直接全量塞给CatmullRomCurve3反而会过拟合,曲线会变得非常抖,看起来像手抖画出来的。
我的处理是先按每10分钟抽一个点,把数百个点压缩成几十个控制点,再用这些点构造样条曲线。核心代码很短:
const curve = new THREE.CatmullRomCurve3( sampledPoints, false, 'catmullrom', 0.5 ); const line = new THREE.Line( new THREE.BufferGeometry().setFromPoints(curve.getPoints(200)), new THREE.LineBasicMaterial({ color: 0x4fc3f7, transparent: true, opacity: 0.8 }) ); scene.add(line);curve.getPoints(200)是最后一步的关键:这200个点不是建模数据,而是渲染用的曲线采样点,让折线看起来足够平滑又不至于浪费顶点。我在项目里还让一个代表飞船的小球体沿着这条曲线运动,按照滚动进度取曲线上的位置。取位置的代码是curve.getPointAt(progress),这个方法是按弧长均匀采样,能避免球体在曲线陡峭段飞得太快的问题。
4.3 渲染循环与光照的取舍
光照方面我做了很大的克制。场景里只放了一个方向光用来在月球表面拉出明暗交界,没有加额外的点光源或环境贴图。原因是月球表面本身是暗灰色,对比度主要靠凹凸纹理,光照一旦多了,噪点会被放大,画面反而显得脏。
渲染循环的优化是关键。默认做法是每一帧都调renderer.render(),但这在滚动叙事项目里很浪费:页面静止时根本不需要重绘。我的做法是把渲染跟滚动进度绑定,用一个dirty标志位,只有progress发生变化时才触发一次渲染。
let lastProgress = 0; function loop() { if (dirty || lastProgress !== currentProgress) { renderer.render(scene, camera); lastProgress = currentProgress; } requestAnimationFrame(loop); }这个做法让画面静止时GPU占用几乎归零。另外我把相机运动也做了平滑处理,把目标点的x、y、z直接按progress线性插值,而不是让相机追着飞船模型跑。为什么?因为一旦相机的目标绑定到移动中的飞船上,滚动抖动和帧率波动会被放大,视觉上就是镜头一直在来回晃,非常晕。绑定到轨迹曲线上的插值点,天然是稳定且可预测的。
5. 移动端与低端机适配:DPR限制、降级开关和真机验收记录
桌面端一切顺利,完全不影响移动端适配的翻车现场。项目在手机上第一次打开时,3D场景直接掉到30帧以下,卡片动画也在滚动时出现明显迟滞。这个章节的结论是:移动端的适配不是加几个媒体查询就够,而是要从渲染策略和资源加载上做分级处理。
5.1 分级降级:从WebGL2到纯文本
我的做法是按设备能力分四个梯队,而不是一刀切。能力越强,效果越全;能力不足,逐级砍掉3D、粒子、甚至动效。
| 设备能力 | 效果档位 | 说明 |
|---|---|---|
| 支持WebGL2且内存充足 | 全量3D | 完整月球、轨迹、粒子星空 |
| 支持WebGL2但设备中端 | 简易3D | DPR上限1.5,关闭抗锯齿,粒子减半 |
| 不支持WebGL2 | 2D星空海报 | 用CSS渐变和发光圆点模拟深空 |
| 极低性能或非常老设备 | 纯文本时间轴 | 保留信息,去掉大部分动效 |
检测逻辑不能完全相信某个单一API,我组合使用WebGL上下文探测和navigator.deviceMemory,但后者在部分浏览器里是undefined,只能当参考。还有一个比较可靠的低端机信号是DPR大于等于3同时设备内存小于4GB,这种情况基本可以提前走2D档位。
const canWebGL2 = (() => { try { const canvas = document.createElement('canvas'); return !!(window.WebGL2RenderingContext && canvas.getContext('webgl2')); } catch (e) { return false; } })(); const isLowEnd = !canWebGL2 || (navigator.deviceMemory && navigator.deviceMemory < 4); if (isLowEnd) { enableFallbackMode(); } else { bootThreeScene(); }这里要特别说明一个容易踩的坑:不要用navigator.userAgent去猜机型。猜机型等于把维护成本焊死在设备列表上,今天能用的列表下个月就过时。老老实实做能力探测和实测打点,比什么都可靠。
5.2 真机验收清单和实测结果
做完分档之后,我列了一张真机验收清单,每项都对应一个可能出事故的点。清单内容不复杂,但每一条在实机上都真实翻过车:
- 连续滚动3分钟,帧率曲线是否平稳,而不是从60fps瞬间跳到20fps;
- 阶段卡片进出场时,有没有因为阴影或滤镜引起整页重绘;
- 横竖屏切换后,3D相机和场景比例是否变形;
- 弱网环境下,3D贴图或字体是否因为加载失败导致白屏;
- 系统省电模式打开后,页面动效是否特别卡顿。
对应的实测结果和处理方式,我整理成了一张简表:
| 测试环境 | 实际表现 | 最终处理 |
|---|---|---|
| 桌面独立显卡 | 稳定60fps | 保持全量3D |
| 中端安卓机 | 滚动时偶发掉帧到45fps | DPR限制为1.5,关闭抗锯齿,粒子减半 |
| 老款手机 | WebGL2初始化失败 | 自动降级到2D星空 |
| 弱网4G加载 | 首屏图片阻塞导致长时间白屏 | 关键资源本地化,字体用woff2子集 |
5.3 资源加载与首屏节奏
移动端另外要注意的是加载体积。我最初把月面贴图和字体文件原样打包,首屏资源堆到了3.8MB,在4G网络下需要好几秒才能看到画面,用户基本等不住。后来做了三件事:贴图压缩成WebP,体积降了六成;字体只保留用到的字符子集,用woff2格式,每款字体压到几十KB;3D场景的JS包按需加载,用户滚动进入需要3D的阶段前才动态注入。首屏体积从3.8MB降到了1.1MB,打开时间体感快了很多。
这一节其实没有太多高深技巧,核心就是敬畏低端设备和弱网环境,给它们留一条体面的退路。
6. 复盘:几个值得带走的做法与两处后续优化方向
项目收尾阶段,我复盘了一下哪些做法真正帮助我节约了时间,又有哪些地方如果再做一次我会采取不同方案。这里只说结论和具体的理由。
6.1 写代码前先把叙事文本敲定
这个项目对我最大的教训是:叙事文本没有定稿之前,不要急着写动画。早期版本我是在代码里直接写阶段文案的,后来文案一改,动画的时长配置、卡片位置全都要跟着动,改起来非常痛苦。后来我把文案和参数抽到一个独立的story.json里,前端只读这个文件来生成阶段卡片和动画配置。
story.json的字段设计其实很朴素,每个阶段放一条记录,包含id、name、highlight、metrics数组,以及一个animation配置。animation里只放两个数字:enterDuration和holdDuration。enterDuration决定卡片进场动画时长,holdDuration决定场景在这一阶段停留多久再进入下一段。这样我自己或者合作者改文案、改节奏时,都不需要碰组件代码。改文案不再碰代码,成本约等于零。强烈建议做任何叙事型可视化项目时,先把story.json这个层建好,把文案、参数、阶段ID全部放进去,当成整个项目的唯一事实来源。
6.2 后续可以扩展的两个方向
如果之后还有时间,我会考虑两个方向。第一个方向是加入任务阶段间的"可比较视角",比如在月面活动阶段,让用户切换"第一人称月面视野"和"轨道俯视视野",增加主动探索的乐趣,而不只是被动滚动。这个需求刚好可以用ScrollTrigger的另一个模式:不做滚动驱动,改成按钮点击驱动同一组动画,本质是把input从scroll换成click,输出框架完全不变。
第二个方向是接入实时数据,比如让当前近地轨道的航天器实时位置数据或月历数据跟固定历史任务时间轴并排展示,让项目的叙事感和"现在进行时"产生对比,形成新的内容层次。这会动到数据层,得在story.json加一个live字段,表示哪个阶段允许打天实时数据;表现层不需要变,卡片和3D场景直接喂新数值就行。
这两个方向都不需要改动现有的驱动骨架,ScrollTrigger加Three.js这套组合可以继续复用,这也说明当初把驱动层和表现层分开的设计是对的。
做完这个项目,我最大的体会是:可视化练习的价值不在于特效有多震撼,而在于有没有把一个真实主题讲清楚。Apollo-11这个代号以后大概还会继续用,下一次我大概会从声音入手,给不同任务阶段配上不同的环境音层,让滚动的节奏不止能被看见,还能被听见。叙事型页面的上限,往往藏在那些用户说不清但能感受到的细节里。