前言
入职新公司后,我的工作方向从应用层迁移到了系统层。出于学习目的,我开始研究 bootanimation 相关的代码。
在不实际运行的情况下,大部分代码勉强能理解其作用,但 BootAnimation.cpp 中有一个 bool BootAnimation::android(const Display& display) 函数,其巧妙的绘制逻辑仅靠脑补难以透彻理解。为了更清晰地掌握它的实现原理,我决定将该函数的绘制过程单独提取出来运行。
由于目的仅是理解其实现方式,运行平台不强制要求是 Android —— 只要提供基础的 OpenGL 环境并配合相同的 assets 文件,就一定能复现出类似的效果, 所以后面会使用threejs复现效果
本文不解释 bootanimation 的整体逻辑,只关注开机后默认的 Android Logo 动画是如何实现的。
资源获取
要用到的资源有android-logo-mask.png和android-logo-shine.png, 可以直接去googlesource获取到了原文件.
也可以从下面另存为


为了方便我写代码和演示, 我把这两个文件放到了站点的 public/images/archives/shine-logo 目录下:
android-logo-mask.png: 512 x 128, 带 alpha 通道, 用来裁出最终可见的 Android logoandroid-logo-shine.png: 2048 x 128, 一张很长的横向高光贴图, 用来在 logo 下面移动
源码中的核心逻辑
BootAnimation.cpp 中默认 Android logo 动画的入口是 bool BootAnimation::android(const Display& display).
这段代码并没有准备一帧帧的图片, 而是只加载两张纹理:
initTexture(&mAndroid[0], mAssets, "images/android-logo-mask.png");
initTexture(&mAndroid[1], mAssets, "images/android-logo-shine.png");
每一帧绘制时, 它先计算 logo 在屏幕中间的位置:
const GLint xc = (display.width - mAndroid[0].w) / 2;
const GLint yc = (display.height - mAndroid[0].h) / 2;
然后根据启动后的时间计算 shine 贴图的横向偏移:
double time = now - startTime;
float t = 4.0f * float(time / us2ns(16667)) / mAndroid[1].w;
GLint offset = (1 - (t - floorf(t))) * mAndroid[1].w;
GLint x = xc - offset;
这里的 16667us 接近 60fps 的一帧时间. 源码虽然最终用 usleep 控制在大约 12fps, 但偏移计算仍然用这个时间单位作为基础. t - floorf(t) 得到的是 0 到 1 之间的循环小数, 于是 offset 会在 mAndroid[1].w 到 0 之间反复变化.
两次绘制 shine 的原因
源码里最有意思的是这里:
drawTexturedQuad(x, yc, mAndroid[1].w, mAndroid[1].h, display);
drawTexturedQuad(x + mAndroid[1].w, yc, mAndroid[1].w, mAndroid[1].h, display);
也就是说, 它不是只画一张 shine, 而是画两张首尾相接的 shine. 当第一张向右移动到快要离开 logo 区域时, 第二张正好补上空缺, 这样循环时不会出现断层.
其实最开始我疑惑为什么不直接把这个纹理设置成
GL_REPETA模式, 这样就无需绘制两次,而是借助GL_REPEAT特性实现无缝绘制. 虽然素材是 2 的幂尺寸,理论上支持 GL_REPEAT,但 Google 工程师选择手动绘制两个纹理,可能是为了规避部分移动 GPU 上 GL_REPEAT 的潜在性能问题或驱动差异,同时保证滚动位置的精确可控。
glEnable(GL_BLEND);
glBindTexture(GL_TEXTURE_2D, mAndroid[0].name);
drawTexturedQuad(xc, yc, mAndroid[0].w, mAndroid[0].h, display);
所以实际视觉效果可以理解成:
- 黑色背景清屏
- 在 logo 所在区域下面移动一条很长的 shine 贴图
- 用带 alpha 的 mask 盖在上面, 只让 Android 字样区域可见
迁移到网页
我在 BootAnimation.astro 中使用了 Three.js 来复现这个过程. 逻辑基本对应源码:
const t = (4 * (elapsed / 16.667)) / 2048;
const offset = (1 - (t - Math.floor(t))) * shineWidth;
const shineX = logoX - offset;
ctx.drawImage(shine, shineX, logoY, shineWidth, shineHeight);
ctx.drawImage(shine, shineX + shineWidth, logoY, shineWidth, shineHeight);
ctx.drawImage(mask, logoX, logoY, logoWidth, logoHeight);
浏览器里的 canvas 尺寸会随页面宽度变化, 所以实现里额外做了缩放:
- 保持 mask 的原始宽高比
512:128 - 根据容器宽度计算
scale - shine 和 mask 使用同一个
scale, 保证两张图仍然对齐 - 用
ResizeObserver在容器变化时重新设置 canvas 分辨率
为了更直观地观察这套公式, 我还引入了 lil-gui 做了两个可调参数:
offset: 在源码计算出的 shine 坐标上额外增加一个手动偏移量speed: 对应源码里的4.0f速度系数, 用来放慢、加速, 或反向播放 shine
也就是说, 原始迁移公式从:
const t = (4 * (elapsed / 16.667)) / 2048;
const offset = (1 - (t - Math.floor(t))) * shineWidth;
const shineX = logoX - offset;
变成了:
const t = (speed * (elapsed / 16.667)) / 2048;
const offset = (1 - (t - Math.floor(t))) * shineWidth;
const shineX = logoX - offset + manualOffset;
这样就可以手动停下来观察每个位置的叠加关系, 也能看到速度变化时循环贴图是否仍然连续.
这里还有一个网页实现特有的小坑: 原始 C++ 代码里的 x 和 offset 都是 GLint, 也就是整数像素. 但浏览器 canvas 为了适配响应式宽度, 会把 512 x 128 的 mask 和 2048 x 128 的 shine 按比例缩放, 两张 shine 首尾相接的位置就可能落在小数像素上. 这时即使逻辑上是无缝的, 视觉上也可能露出一条 1px 的黑线.
所以网页版本里先在源图坐标系计算整数 offset 和 x, 再乘以 scale 映射到 canvas, 并让第二张 shine 向左重叠约 1 个源像素. 这不改变原始动画逻辑, 只是修掉浏览器缩放后的亚像素接缝.
下面就是迁移后的实时效果:
::boot-animation::
写在最后
一个有趣的细节是:我最把 android-logo-shine.png 错看成是一张从黑到白的简单渐变图,直到下载原图才发现它实际上是多段渐变(明-暗-明)。文章虽然写完了,但这个小插曲值得记录下来。
Android 开机动画复现
根据 BootAnimation.cpp 中 android() 的绘制顺序迁移