继上一篇 Blog 把 SPI 触屏屏幕点亮之后,觉得整体看起来优雅美观,越看越像一个东西。。。
真的,一块 3.5 寸 480×320 的 SPI LCD 跑在 RDK X5 上,显示桌面、触摸正常——已经能用了。但一个嵌入式屏光跑桌面,总觉得少了点什么。它需要一个灵魂。
我脑海里突然闪过了国泰航空的 IFE(机上娱乐系统)开机视频。只要你坐过国泰的航班,那个画面绝对会印在脑子里:深邃的星空背景、翻牌式滚动的航班信息、3D 地球,还有那架沿着航线缓缓飞行的客机。
先看效果
【复刻国泰航空机上娱乐系统IFE第二弹 - Shockley | 小红书 - 你的生活兴趣社区】
HfJRSklRSjduhHa
https://www.xiaohongshu.com/discovery/item/6a16ba57000000000702a17c?source=webshare&xhsshare=pc_web&xsec_token=CBi0USC7K9heSOyQjGUyjt4P7-BPYK6cY9k0AfDWcxZb4=&xsec_source=pc_share
整个动画是一个 4 状态有限状态机,自动循环 31 条国泰真实航线:
START_SCREEN → GLOBE_SCENE → DEST_REVEAL → GLOBE_ARRIVE
↑ |
└────────────────────────────────────┘
- START_SCREEN:星空背景 + 7 国语言选择菜单(English / 繁體中文 / 简体中文 / 日本語 / 한국어 / Français / Deutsch)
- GLOBE_SCENE:3D 地球上飞机起飞,沿大圆航线飞行
- DEST_REVEAL:翻牌式登机口显示航班信息 + 目的地名滚动揭示
- GLOBE_ARRIVE:相机拉近目的地,地球倾斜显示,然后自动循环下一条航线
星空启动屏
启动屏是整个动画的第一印象。用 Canvas 2D 画星空——两层粒子叠加,一层远景小而慢,一层近景大而快,加一个微弱的闪烁周期。
7 国语言菜单用 CSS Grid 排列,选中语言后决定后续所有目的地名称的显示语言。比如选了简体中文,后面地球上的目的地就是北京、上海、台北;选了日本語就是北京、上海、台北(日文汉字)。
翻牌登机口——CSS 3D rotateX
IFE 动画里最标志性的元素就是那个翻牌(split-flap)登机口显示器。每个字符格子是一个 CSS 3D rotateX 动画——上半片向下翻到 90°,下半片从 -90° 翻到 0°,两片拼接成新字符。
纯 CSS 实现,不需要 Canvas 或 WebGL:
.flap-cell-top {
transform-origin: bottom center;
animation: flip-top 0.3s ease-in forwards;
}
.flap-cell-bottom {
transform-origin: top center;
animation: flip-bottom 0.3s 0.3s ease-out forwards;
}
关键是 transform-origin 的设置——上半片从底边翻,下半片从顶边翻,视觉上就是一片向下翻遮住旧字符,另一片从背面翻出新字符。加上 perspective 和微妙的阴影渐变,在 480×320 的小屏上看起来也像真实的机械翻牌。
翻牌的字符集是 ASCII 大写字母 + 数字 + 空格,和真实机场登机口一样。航班号、目的地、状态全走翻牌动画逐格翻转。
3D 地球——globe.gl + Three.js
这是整个项目最重的一块。globe.gl 封装了 Three.js,提供了一个开箱即用的 3D 地球组件,支持纹理、弧线、点标记、自定义对象等。我基于它做了几个核心功能:
大圆航线
地球上的弧线不是直线,是大圆航线(great-circle route)——球面上两点间的最短路径。globe.gl 的 arcsData 接受起点/终点坐标 + 弧线高度,自动画出 3D 弧线。31 条航线从 HKG 出发,高度根据距离自动缩放:短途 <2000km 低弧,长途 >6000km 高弧。
低多边形 3D 飞机
原本想用 2D 贴图(plane.png),但在小屏上总是看起来"飘"在地球表面,没有立体感。于是用 Three.js 程序化建模了一个低多边形飞机:
- 机身:拉长的八面体
- 机翼:扁平的四面体,向后掠
- 尕翼:小四面体
总共 7 个面,14 个顶点,没有纹理——就是纯几何体。但在 480×320 的分辨率下反而恰到好处:多了细节也看不清,低多边形在这个尺寸下显得干净利落。
飞机的一个关键设计是屏幕空间恒定大小。不管相机远近,飞机在屏幕上的投影尺寸始终保持 28 像素。实现方式:在 requestAnimationFrame 循环里读取飞机的 3D 位置,根据相机距离动态调整 Sprite scale:
const desiredScreenSize = PLANE_SCREEN_SIZE_PX; // 28
const distance = camera.position.distanceTo(planeSprite.position);
const scale = desiredScreenSize * distance / screenHeight;
planeSprite.scale.set(scale, scale, 1);
这样飞机在远景时不会变成一个点,近景时不会巨大无比,始终保持可辨识的尺寸。
相机跟随飞机
飞行阶段,相机不是固定的。每帧读取飞机的 3D 位置,转换成经纬度,然后调用 globe.pointOfView({lat, lng, altitude}, 300)——300ms 的平滑过渡让相机像卫星一样追踪飞机。
到达目的地时,相机自动拉近(altitude 从飞行高度降到 0.35),地球倾斜显示——通过修改 camera.projectionMatrix.elements[9] 实现垂直偏移,把地球推到屏幕下半部分,上半部分留给目的地名称和航班信息。
昼夜效果
地球的昼夜纹理混合是一个自定义 shader。白天地图和黑夜地图两张纹理,根据太阳位置计算每个点的光照强度,混合两者:
uniforms: {
uDayMap: { value: dayTexture },
uNightMap: { value: nightTexture },
uSunPosition: { value: new THREE.Vector3(...) }
}
太阳位置固定在香港正午、夏至——经度 114.17°,纬度 23.5°。这不是实时天文计算,而是刻意选择:国泰航空的枢纽就是香港,让香港永远在白天,其他城市根据经度偏移自动进入夜晚,城市灯光纹理在黑夜区域亮起。
31 条真实航线
destinations.js 里是 31 条国泰真实航线,每条都有英文名、繁体中文、简体中文、登机口用大写英文名:
{
code: 'CX888',
destination: 'London',
destinationNameCn: '倫敦',
destinationNameCnS: '伦敦',
destinationNameBoard: 'LONDON',
lat: 51.5074, lng: -0.1278,
...
}
语言选择决定了哪个名字字段被显示。CJK 字符(中日韩)在滚动揭示动画中用更宽的列(40px vs 26px)和更大的字号(42px vs 38px),非 CJK 保持窄列斜体风格。
硬件约束下的优化
。几个关键的优化点:
-
WebGL 软渲染:SPI LCD 走的是 card0(panel-mipi-dbi DRM),没有 GPU 渲染节点。WebGL 只能用 Mesa swrast 软渲染。这意味着所有 3D 效果(地球、飞机、昼夜 shader)都跑在 CPU 上,帧率天然受限。globe.gl 的渲染精度已经调低(
globeWidth、globeHeight匹配实际分辨率),不会超量渲染。 -
字体大小:所有 UI 字体在 480×320 下必须足够大才能看清。登机口文字 36px,目的地名 44px,菜单项 20px——不是"响应式设计",而是针对这个固定分辨率的绝对值设计。
-
动画帧率:CSS 动画(翻牌、滚动文字)不受 WebGL 帧率影响,浏览器自己处理。3D 部分在软渲染下大约 10-15 FPS,但因为是缓慢的飞行动画,视觉上不会觉得卡顿。相机过渡用
pointOfView的 300ms 缓动,不依赖帧率。
部署:systemd 拉起 Firefox kiosk
RDK X5 上没有 Chromium,用的是 Firefox kiosk 模式。systemd 服务配置:
[Unit]
Description=Cathay IFE Kiosk
After=graphical.target
[Service]
Type=simple
User=sunrise
Environment=DISPLAY=:0
ExecStartPre=/bin/sh -c 'xdotool mousemove 9999 9999'
ExecStart=firefox --kiosk http://localhost:8000/index.html
ExecStartPost=/bin/sh -c 'unclutter-xfixes --timeout 0 --fork'
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical.target
ExecStartPre 用 xdotool 把鼠标移到屏幕外,ExecStartPost 用 unclutter-xfixes 自动隐藏光标(--timeout 0 表示立即隐藏,--fork 表示后台运行)。这样开机后屏幕上没有任何光标,纯沉浸式体验。
代码同步用 rsync:
sshpass -p 'sunrise' rsync -az \
--exclude='.git' --exclude='node_modules' --exclude='*.mp4' \
--exclude='frames' --exclude='venv' --exclude='.claude' \
--exclude='archive' \
./ sunrise@192.168.128.10:/home/sunrise/cathay_ui/
刷新后重启 kiosk:
sshpass -p 'sunrise' ssh sunrise@192.168.128.10 "sudo systemctl restart cathay-kiosk"
回过头看
从上一篇把 SPI LCD 点亮,到这篇把 IFE 动画复刻出来,整条链路是这样的:
内核 DRM 驱动 (panel-mipi-dbi) → SPI DMA → ST7796S LCD 点亮
↓
X11 modesetting → /dev/fb0 → 桌面显示
↓
Firefox kiosk → HTML/CSS/JS → IFE 动画
每一步都是"能用但不满足"——屏亮了但没有内容,桌面显示了但不美观,动画跑了但不沉浸。每一步的升级都是对"完整产品"的追求:不只是技术上能跑,而是视觉上真的好看。
480×320 的 SPI LCD 不是什么高端显示屏。但当你看到那片星空、翻牌翻出航班号、3D 地球上飞机沿弧线飞向目的地——你会觉得它不只是"一块能亮的屏",而是一个完整的体验。
这就是嵌入式 UI 的意义:不在于硬件有多强,在于你用它做了什么。
项目代码:github.com/shockley6668/Cathay-Launch-UI
SPI LCD 驱动教程见上一篇博客: AI Agent 驱动开发范式:在 RDK X5 上开发 SPI LCD 的内核态 DRM 渲染与点屏