Following up on my previous blog post about getting the SPI touchscreen display working, I kept looking at it and thinking the whole setup looked sleek and polished — it increasingly resembled a real product…
A 3.5" 480×320 SPI LCD running on the RDK X5, displaying a desktop with working touch — it works. But an embedded screen just showing a desktop always feels like it’s missing something. It needs a soul.
Then it hit me — the Cathay Pacific IFE (in-flight entertainment) boot screen. If you’ve ever been on a Cathay flight, that image is absolutely burned into your memory: a deep starfield, split-flap flight info scrolling by, a 3D globe, and that airplane gently tracing its route across the sky. Could it be replicated on such a tiny screen?
The Result

The entire animation is a 4-state finite state machine, auto-cycling through 31 real Cathay routes:
START_SCREEN → GLOBE_SCENE → DEST_REVEAL → GLOBE_ARRIVE
↑ |
└────────────────────────────────────┘
- START_SCREEN: Starfield background + 7-language selection menu (English / 繁體中文 / 简体中文 / 日本語 / 한국어 / Français / Deutsch)
- GLOBE_SCENE: Airplane departs on the 3D globe, following a great-circle route
- DEST_REVEAL: Split-flap departure board shows flight info + destination name rolling reveal
- GLOBE_ARRIVE: Camera zooms into destination, earth tilts, then auto-cycles to the next route
Starfield Boot Screen
The boot screen is the animation’s first impression. I drew the starfield with Canvas 2D — two layers of particles overlaid: a far layer (small, slow) and a near layer (large, fast), with a subtle flicker cycle. This isn’t a Three.js WebGL scene — plain Canvas is enough, and at 320×480 resolution you don’t need many particles anyway.
The 7-language menu uses CSS Grid. The selected language determines how destination names are displayed throughout the rest of the animation. Pick Simplified Chinese and destinations show as 北京、上海、台北; pick Japanese and they appear as 北京、上海、台北 (in Japanese kanji).
Split-Flap Departure Board — CSS 3D rotateX
The most iconic element of the IFE animation is the split-flap departure board display. Each character cell is a CSS 3D rotateX animation — the top half flips downward to 90°, the bottom half flips from -90° to 0°, and the two halves join to reveal the new character.
Pure CSS — no Canvas or WebGL needed:
.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;
}
The key is transform-origin — the top half flips from its bottom edge, the bottom half from its top edge. Visually, one flap drops down covering the old character, while the other flips from its back revealing the new one. With perspective and subtle shadow gradients, it looks like a real mechanical split-flap even on a 480×320 screen.
The character set is ASCII uppercase letters + digits + spaces, just like real airport departure boards. Flight number, destination, and status all animate through the split-flap cell-by-cell.
3D Globe — globe.gl + Three.js
This is the heaviest part of the project. globe.gl wraps Three.js into a ready-to-use 3D globe component with support for textures, arcs, point markers, and custom objects. I built several core features on top of it:
Great-Circle Routes
Arcs on a globe aren’t straight lines — they’re great-circle routes, the shortest path between two points on a sphere. globe.gl’s arcsData takes start/end coordinates + arc height and automatically draws 3D arcs. The 31 routes depart from HKG with altitude scaled by distance: short haul <2000km gets a low arc, long haul >6000km gets a high arc.
Low-Poly 3D Airplane
I originally tried a 2D sprite (plane.png), but on the small screen it always looked like it was “floating” on the globe surface with no depth. So I procedurally modeled a low-poly airplane in Three.js:
- Fuselage: elongated octahedron
- Wings: flat tetrahedrons, swept back
- Tail fin: small tetrahedron
7 faces, 14 vertices, no texture — pure geometry. But at 480×320 resolution, it’s actually perfect: more detail wouldn’t be visible anyway, and low-poly looks clean and sharp at this size.
A key design decision is screen-space constant size. No matter how far or near the camera, the airplane’s projected size on screen stays at 28 pixels. Implementation: in the requestAnimationFrame loop, read the airplane’s 3D position, calculate distance from camera, and dynamically adjust the 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);
This means the airplane never shrinks to a dot at distance or balloons to absurd size up close — it stays recognizable at all times.
Camera Tracking the Airplane
During flight, the camera isn’t fixed. Each frame reads the airplane’s 3D position, converts it to lat/lng, then calls globe.pointOfView({lat, lng, altitude}, 300) — a 300ms smooth transition that makes the camera track the airplane like a satellite.
On arrival, the camera automatically zooms in (altitude drops from flight level to 0.35), and the earth tilts — achieved by modifying camera.projectionMatrix.elements[9] to shift vertically, pushing the globe to the bottom half of the screen and leaving the top half for the destination name and flight info.
Day/Night Effect
The globe’s day/night texture blending uses a custom shader. Two textures — a daytime map and a nighttime map — are mixed based on sun position, calculating illumination intensity at each point:
uniforms: {
uDayMap: { value: dayTexture },
uNightMap: { value: nightTexture },
uSunPosition: { value: new THREE.Vector3(...) }
}
The sun is fixed at Hong Kong noon on the summer solstice — longitude 114.17°, latitude 23.5°. This isn’t real-time astronomical calculation; it’s a deliberate choice: Cathay Pacific’s hub is Hong Kong, so Hong Kong is always in daylight, other cities naturally shift into night based on longitude, and city lights textures illuminate the dark regions.
31 Real Routes
destinations.js contains 31 real Cathay routes, each with an English name, Traditional Chinese, Simplified Chinese, and uppercase English name for the departure board:
{
code: 'CX888',
destination: 'London',
destinationNameCn: '倫敦',
destinationNameCnS: '伦敦',
destinationNameBoard: 'LONDON',
lat: 51.5074, lng: -0.1278,
...
}
The language selection determines which name field is displayed. CJK characters (Chinese/Japanese/Korean) use wider columns (40px vs 26px) and larger font size (42px vs 38px) in the rolling reveal animation, while non-CJK text keeps narrow columns with italic style.
Optimization Under Hardware Constraints
480×320 landscape, ARM64 with no GPU — this isn’t a desktop browser. Key optimization points:
-
WebGL software rendering: The SPI LCD uses card0 (panel-mipi-dbi DRM), which has no GPU render node. WebGL only works with Mesa swrast software rendering. All 3D effects (globe, airplane, day/night shader) run on CPU, so frame rate is inherently limited. globe.gl’s render resolution is tuned down (
globeWidth,globeHeightmatch actual resolution) to avoid over-rendering. -
Font sizes: All UI fonts must be large enough to read at 480×320. Departure board text 36px, destination names 44px, menu items 20px — not “responsive design”, but absolute-value design targeted at this fixed resolution.
-
Animation frame rate: CSS animations (split-flap, rolling text) aren’t affected by WebGL frame rate — the browser handles them independently. The 3D portion runs at roughly 10-15 FPS under software rendering, but since it’s a slow flight animation, it doesn’t feel stuttery. Camera transitions use
pointOfView’s 300ms easing, which is frame-rate-independent.
Deployment: systemd Launches Firefox Kiosk
There’s no Chromium on the RDK X5 — Firefox kiosk mode is what we use. The systemd service config:
[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 uses xdotool to push the cursor off-screen; ExecStartPost runs unclutter-xfixes to auto-hide the cursor (--timeout 0 = hide immediately, --fork = run in background). After boot, there’s no cursor on screen — pure immersive experience.
Code sync via 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/
After syncing, restart the kiosk:
sshpass -p 'sunrise' ssh sunrise@192.168.128.10 "sudo systemctl restart cathay-kiosk"
Looking Back
From the previous blog post about getting the SPI LCD lit up, to this one replicating the IFE animation, the full chain looks like this:
Kernel DRM driver (panel-mipi-dbi) → SPI DMA → ST7796S LCD lit up
↓
X11 modesetting → /dev/fb0 → Desktop display
↓
Firefox kiosk → HTML/CSS/JS → IFE animation
Each step was “working, but not satisfying” — the screen was lit but had no content; the desktop was displayed but not aesthetically pleasing; the animation was running but not immersive. Each upgrade was a pursuit of a “complete product”: not just technically functional, but visually compelling.
A 480×320 SPI LCD is no premium display. But when you see that starfield, the split-flap rolling out flight numbers, and the 3D globe with an airplane tracing arc routes toward a destination — you realize it’s not just “a lit screen”, it’s a complete experience.
That’s what embedded UI is about: not how powerful the hardware is, but what you do with it.
Project code: github.com/shockley6668/Cathay-Launch-UI
SPI LCD driver tutorial: see
SPI-LCD-SETUP.mdin the repo