Preface
After joining the new company, my work direction moved from the application layer to the system layer. For learning purposes, I started studying bootanimation related code.
Without actually running it, most of the code can barely understand its function, but there is a bool BootAnimation::android(const Display& display) function in BootAnimation.cpp, and its clever drawing logic is difficult to fully understand just by relying on imagination. In order to grasp its implementation principle more clearly, I decided to extract the drawing process of this function separately and run it.
Since the purpose is only to understand its implementation, the running platform is not required to be Android - as long as a basic OpenGL environment is provided and the same assets file is used, a similar effect can be reproduced, so threejs will be used to reproduce the effect later.
This article does not explain the overall logic of bootanimation, but only focuses on how the default Android Logo animation is implemented after booting.
Resource acquisition
The resources to be used are android-logo-mask.png and android-logo-shine.png. You can directly go to googlesource to get the original files.
You can also save it as


In order to make it easier for me to write code and demonstrate, I put these two files in the public/images/archives/shine-logo directory of the site:
android-logo-mask.png: 512 x 128, with alpha channel, used to crop out the final visible Android logoandroid-logo-shine.png: 2048 x 128, a long horizontal highlight map, used to move under the logo
Core logic in source code
The default Android logo animation entry in BootAnimation.cpp is bool BootAnimation::android(const Display& display).
This code does not prepare a frame of images, but only loads two textures:
initTexture(&mAndroid[0], mAssets, "images/android-logo-mask.png");
initTexture(&mAndroid[1], mAssets, "images/android-logo-shine.png");
When drawing each frame, it first calculates the position of the logo in the middle of the screen:
const GLint xc = (display.width - mAndroid[0].w) / 2;
const GLint yc = (display.height - mAndroid[0].h) / 2;
Then calculate the lateral offset of the shine map based on the time since startup:
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;
Here 16667us is close to a frame time of 60fps. Although the source code is finally controlled at about 12fps using usleep, the offset calculation still uses this time unit as the basis. t - floorf(t) gets a recurring decimal between 0 and 1, so offset will be in mAndroid[1].w changes repeatedly between 0 and 0.
The reason why shine is drawn twice
The most interesting thing in the source code is here:
drawTexturedQuad(x, yc, mAndroid[1].w, mAndroid[1].h, display);
drawTexturedQuad(x + mAndroid[1].w, yc, mAndroid[1].w, mAndroid[1].h, display);
In other words, it does not draw just one shine, but draws two shines connected end to end. When the first one moves to the right and is about to leave the logo area, the second one just fills the gap, so that there will be no gaps during the cycle.
In fact, at first I wondered why I didn’t just set this textures to
GL_REPETAmode, so that I don’t need to draw it twice, but use theGL_REPEATfeature to achieve seamless drawing. Although the material is a power of 2 size and theoretically supports GL_REPEAT, Google engineers chose to draw two textures manually, probably to avoid some mobile GPUs. GL_REPEAT potential performance issues or driver differences while ensuring precise controllability of scroll position.
glEnable(GL_BLEND);
glBindTexture(GL_TEXTURE_2D, mAndroid[0].name);
drawTexturedQuad(xc, yc, mAndroid[0].w, mAndroid[0].h, display);
So the actual visual effect can be understood as:
- Clear screen with black background
- Move a long shine texture below the area where the logo is located
- Cover it with a mask with alpha, so that only the Android word area is visible
Move to web
I used Three.js in BootAnimation.astro to reproduce this process. The logic basically corresponds to the source code:
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);
The canvas size in the browser will change with the page width, so additional scaling is done in the implementation:
- Keep mask’s original aspect ratio
512:128 - Calculated based on container width
scale - Shine and mask use the same
scaleto ensure that the two images are still aligned - Use
ResizeObserverto reset canvas resolution when container changes
In order to observe this set of formulas more intuitively, I also introduced lil-gui and made two adjustable parameters:
offset: Add an additional manual offset to the shine coordinates calculated by the source codespeed: Corresponds to the4.0fspeed coefficient in the source code, used to slow down, speed up, or reverse playback shine
That is, the original migration formula goes from:
const t = (4 * (elapsed / 16.667)) / 2048;
const offset = (1 - (t - Math.floor(t))) * shineWidth;
const shineX = logoX - offset;
became:
const t = (speed * (elapsed / 16.667)) / 2048;
const offset = (1 - (t - Math.floor(t))) * shineWidth;
const shineX = logoX - offset + manualOffset;
In this way, you can manually stop and observe the superposition relationship of each position, and you can also see whether the loop map is still continuous when the speed changes.
There is another pitfall unique to web page implementation: x and offset in the original C++ code are both GLint, which is an integer pixel. However, in order to adapt to the responsive width, the browser canvas will use the mask of 512 x 128 and the shine of 2048 x 128 Scaled proportionally, the position where two shines are connected end to end may fall on a decimal pixel. At this time, even if it is logically seamless, a 1px black line may be visually exposed.
Therefore, in the web version, the integers offset and x are first calculated in the source image coordinate system, then multiplied by scale to map to the canvas, and the second shine overlaps about 1 source pixel to the left. This does not change the original animation logic, but only removes the sub-pixel seam after the browser zooms.
The following is the real-time effect after migration:
::boot-animation::
write at the end
An interesting detail is: I most mistook android-logo-shine.png as a simple gradient image from black to white. It wasn’t until I downloaded the original image that I discovered that it was actually a multi-section gradient (light-dark-light). Although the article is finished, this episode is worth recording.
Android 开机动画复现
根据 BootAnimation.cpp 中 android() 的绘制顺序迁移