手柄君的小阁

个人私货聚集地

模仿QQ登录窗口html5动画实现

本文最后更新于 2018 年 3 月 18 日,其中的内容可能有所发展或发生改变,敬请注意。

有什么方法可以模仿着实现QQ登录窗口动画呢?于是手柄开始了摸索

最终效果

最终效果浏览 (点击查看原图)

https://www.bysb.net/study/180303/login.html

思路

  1. 仔细查看原版动画,找出包含组件
  2. 仔细查看原版动画,感受运动效果
  3. 使用canvas实现单个组件绘制
  4. 使用canvas绘制出所有组件(不运动)
  5. 设置运动函数,为组件套用运动函数
  6. 查找缺失部分
  7. 模拟缺失部分效果

拆分动画

QQ登录窗口原版动画 (点击查看原图)

包含组件

  1. 大量的三角形拼接成的动画主体
  2. QQ LOGO
  3. 右上角的按钮

运动效果

  1. 每个三角形颜色会有类似线性渐变效果
  2. 三角形的三个点有缓入缓出运动轨迹

组件绘制

因只有三角形拼接的动态背景打算使用canvas,其余部分可使用css和html实现效果,故本文略过不做说明,欢迎下载源代码查看。

单个组件

既然前文打算使用canvas模拟实现动画,那么,已知单个组件为三角形,则可以设置一个绘制三角形的函数。
三角形在canvas2d中没有提供现成函数,但我们可以通过首先绘制一个三角形路径,然后填充来绘制。

39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
 * @type {HTMLCanvasElement} canvas 绘图板
 */
let canvas = document.getElementById("loginAnime");
let ctx = canvas.getContext("2d", { alpha: false });
/**
 * @description 绘制一个三角形
 * @param {Array<number>} a 点A坐标[x,y]
 * @param {Array<number>} b 点B坐标[x,y]
 * @param {Array<number>} c 点C坐标[x,y]
 * @param {String} color 要绘制的颜色
 */
function drawTriangle(a, b, c, color) {
    ctx.fillStyle = color;
    ctx.beginPath();
    .moveTo(a[0], a[1]);
    ctx.lineTo(b[0], b[1]);
    ctx.lineTo(c[0], c[1]);
    ctx.fill();
}

对函数进行测试,随意地绘制三个三角形,设置为三个颜色。

61
62
63
drawTriangle([10, 10], [50, 50], [0, 30], "#FF0000");
drawTriangle([60, 45], [170, 130], [110, 20], "#00FF00");
drawTriangle([300, 300], [300, 400], [200, 350], "#0000FF");

可以发现,三个三角形都正确地进行了绘制

所有三角形组件

既然已经绘制出了一个三角形,那么根据原动画,可以作为每2*2个点绘制1*1*2个三角形,每4*3个点绘制3*2*2个三角形,再考虑到由于存在三角形顶点移动的可能性,故设置 宽度12,高度4的二维数组,数组每个项记录有一个高度2的一维数组,包含每个点的x和y坐标,为了点看上去不那么规则,设置一个随机数加上原始坐标得到点的坐标。

305
306
points[i][j][0] = j * 140 - 600 + parseInt(Math.random() * 100);
points[i][j][1] = i * 188 - 120 + parseInt(Math.random() * 20);

每2*2个点绘制得到(2-1)*(2-1)*2个三角形

以上可以得到每个三角形的三个顶点坐标,但是由于前面提到的,12*4个点,实际能够绘制的三角形数量是11*3*2个三角形,因为点是确定的,我们只需要为每个三角形记录颜色即可。为了方便,我将同一个四边形内的两个三角形的颜色放置在了两个不同数组的相同下标下分别存储,且存储时使用一个高度3的数组,分别对应r,g,b的数值。

204
205
206
207
208
209
/**
 * @description 随机得到一个rgb颜色数组
 */
function getRandomRGB() {
    return [parseInt(Math.random() * 255), parseInt(Math.random() * 255), parseInt(Math.random() * 255)];
}
353
354
shapeColorUPFrom[i][j] = getRandomRGB();
shapeColorDOWNFrom[i][j] = getRandomRGB();

完全随机的颜色拼接的背景

运动效果

点的缓动效果

很明显的,每一个点都带有一个缓动效果,前一半效果类似于

f(x) = x² 在 x ∈ [0,1] 的表现

后一半效果则类似于

f(x) = 1 - (1-x)² 在 x ∈ [0,1] 的表现

函数图像

根据数学计算,可以得到一个输入  x ∈ [0,1], 并按照 x ∈ [0,0.5) 或 x ∈ [0.5,1] 返回对应 f(x) 的值,组成一个连贯图像,实现代码如下

72
73
74
75
76
77
78
79
80
81
82
83
84
/**
 * @param {number} percentComplete 移动完成百分比,最大1
*/
function makeEaseInOut2(percentComplete) {
    if (percentComplete < 0.5) {
        percentComplete *= 2;
        return Math.pow(percentComplete, 2) / 2;
    } else {
        percentComplete = 1 - percentComplete;
        percentComplete *= 2;
        return 1 - Math.pow(percentComplete, 2) / 2;
    }
}

最终实现的函数曲线

接下来,我们为每个点设置一个移动到的目标点,并设置一个单程移动需要的时间,通过随机数模拟相对自然的效果。因为写这个动画时候才疏学浅所以三维二维数组构造方式略蠢求轻喷

233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
/**
 * @description 得到一个2维数组
 * @type {Array<Array<>>}
 * @param {number} x 第一层大小
 * @param {number} y 第二层大小
*/
function creave2xArray(x, y) {
    let re = new Array(x);
    for (let i = 0; i < x; i++) {
        re[i] = new Array(y);
    }
    return re;
}
/**
 * @description 得到一个3维数组
 * @type {Array<Array<Array<>>>}
 * @param {number} x 第一层大小
 * @param {number} y 第二层大小
 * @param {number} z 第三层大小
*/
function creave3xArray(x, y, z) {
    let re = new Array(x);
    for (let i = 0; i < x; i++) {
        re[i] = new Array(y);
        for (let j = 0; j < y; j++) {
            re[i][j] = new Array(z);
        }
    }
    return re;
}
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
/**
 * 点用二维数组 包含 一维数组(实际三维,厚度2)
 * @type {Array<Array<Array<number>>>} 坐标点记录[行][列] [0]=>x [1]=>y
 */
let points = creave3xArray(4, 12, 2);
/**
 * 目标点位置用二维数组 包含 一维数组(实际三维,厚度2)
 * @type {Array<Array<Array<number>>>} 坐标点记录[行][列] [0]=>x [1]=>y
*/
let pointsTarget = creave3xArray(4, 12, 2);
/**
 * 点单程移动需要时间用二维数组
 * @type {Array<Array<number>>} 坐标点记录[行][列] [0]=>x [1]=>y
*/
let pointsMoveTime = creave2xArray(4, 12);
for (let i = 0; i < 4; i++) {
    for (let j = 0; j < 12; j++) {
        points[i][j][0] = j * 140 - 600 + parseInt(Math.random() * 100);
        points[i][j][1] = i * 188 - 120 + parseInt(Math.random() * 20);;
        pointsTarget[i][j][0] = points[i][j][0] + parseInt(Math.random() * 260);
        pointsTarget[i][j][1] = points[i][j][1] + parseInt(Math.random() * 20);
        pointsMoveTime[i][j] = 5000 + parseInt(Math.random() * 3000);
        //pointsMoveTime[i][j] = 500 + parseInt(Math.random() * 1000);//某人恶趣味的速度
    }
}

通过window.requestAnimationFrame实现每秒60次的函数调用,回调函数带有一个入参,为一个高精度时间戳,同performance.now()

313
314
315
316
317
/**
 * 点在特定时间的位置用二维数组 包含 一维数组(实际三维,厚度2)
 * @type {Array<Array<Array<number>>>} 坐标点记录[行][列] [0]=>x [1]=>y
*/
let pointsNow = creave3xArray(4, 12, 2);
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
//每秒60次重绘
function reDraw(timestamp) {
    let time = extraTime + timestamp
 
    //重算坐标位置
    for (let i = 0; i < 4; i++) {
        for (let j = 0; j < 12; j++) {
            pointsNow[i][j] = getMovePoint(points[i][j], pointsMoveTime[i][j], time, pointsTarget[i][j][0], pointsTarget[i][j][1]);
        }
    }
 
    //绘制前最后的准备
    for (let j = 0; j < 11; j++) {
        for (let i = 0; i < 3; i++) {
            //绘制图像
            drawTriangle(pointsNow[i][j], pointsNow[i][j + 1], pointsNow[i + 1][j], shapeColorUP[i][j])
            drawTriangle(pointsNow[i][j + 1], pointsNow[i + 1][j], pointsNow[i + 1][j + 1], shapeColorDOWN[i][j])
        }
    }
    window.requestAnimationFrame(reDraw);
}
window.requestAnimationFrame(reDraw);

实现效果如图

缓动效果 (点击查看原图)

颜色的线性变化

颜色的变化感觉上像是线性变化,正由于前面提到的,颜色使用数组存储,那么我们可以方便的写出一个函数,带有三个参数,两个数组对应起始颜色和目标颜色,另一个参数代表百分比,得到如下代码

157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
/**
 * @description 按照等速渐变,得到某点在t毫秒时的颜色,返回一个字符串
 * @param {Array<number>} from 来源颜色
 * @param {number} t 单程移动所需时间(ms)
 * @param {number} g 已经经过的时间
 * @param {Array<number>} to 目标颜色
 */
function getMoveColor(from, to, t, gone) {
    let persent = gone / t;
    persent %= 2;
    if (persent > 1) persent = 2 - persent;
    let r = from[0] + (to[0] - from[0]) * persent;
    let g = from[1] + (to[1] - from[1]) * persent;
    let b = from[2] + (to[2] - from[2]) * persent;
 
    r = parseInt(r);
    g = parseInt(g);
    b = parseInt(b);
 
    return "rgb(" + r + "," + g + "," + b + ")";
}

线性渐变的颜色 (点击查看原图)

然后修改颜色,让起始颜色和目标颜色更加美观

204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
/**
 * @description 随机得到一个rgb颜色数组
 */
function getRandomRGB() {
    let r = 0;
    let randomNum = Math.random();
    let g = 130 + parseInt(randomNum * 30);
    let b = 160 + parseInt(randomNum * 50);
    return [r, g, b];
}
 
/**
 * @description 随机得到一个亮一些的rgb颜色数组
 */
function getRandomRGBLight() {
    let r = 0;
    let randomNum = Math.random();
    randomNum = randomNum / 2 + 0.5
    let g = 140 + parseInt(randomNum * 45);
    let b = 180 + parseInt(randomNum * 55);
    return [r, g, b];
}

慢慢调整颜色后的效果 (点击查看原图)

至此,带有运动函数的基本动态背景图绘制完成。

查漏补缺

重新对比原版QQ,感觉似乎少了点什么?

Q登录窗口动画 (点击查看原图)

原版QQ有一种流光的感觉,然而目前我们实现的效果中对比发现并没有,接下来尝试实现流光效果。

首先观察原版,流光效果的光源更类似于点光源,但是对于单独每个三角形,每个位置的亮度是相等的,可整理出以下特点

  1. 同一个三角形同时每个像素亮度一致
  2. 光源类似于点光源
  3. 光源位置在不断变化
  4. 三角形亮度只和三角形与光源的距离有关

那么我们可能想要实现的有

  1. 记录一个光源坐标,并且移动它
  2. 计算每个不同三角形的距离和亮度
  3. 在计算某个时间三角形颜色的同时,计算亮度变化后的颜色

记录光源坐标并移动

这里使用一个数组记录光源的坐标,使用随机数设置一个随机的初始位置,将整个图形抽象为三角形组合矩形组合的更大的矩形,按照每一个小矩形长宽为1来计算

343
344
345
346
347
/**
 * 当前高亮位置,x,y
 * @type {Array<number>} 高亮位置x,y
*/
let nowHiLight = [1 + Math.random(), 5 + Math.random()];

自然的移动需要速度,而速度需要加速度,加速度随时都在随机而变而速度不会突然大幅度变化,在这里,加速度没有用单独的变量记录,而是直接使用随机数来代替

347
let nowHiLightSpeed = [Math.random() * 0.06 - 0.03, Math.random() * 0.1 - 0.05];
379
380
381
382
383
384
385
386
387
//计算最新的高亮移动速度
nowHiLightSpeed[0] += (Math.random() - 0.5) * 0.028;
nowHiLightSpeed[1] += (Math.random() - 0.5) * 0.04;
 
//限制最大速度
if (nowHiLightSpeed[0] > 0.3) nowHiLightSpeed[0] = 0.3;
if (nowHiLightSpeed[0] < -0.3) nowHiLightSpeed[0] = -0.3;
if (nowHiLightSpeed[1] > 0.6) nowHiLightSpeed[1] = 0.6;
if (nowHiLightSpeed[1] < -0.6) nowHiLightSpeed[1] = -0.6;

计算三角形距离和亮度

在这里,因为三角形移动幅度不大,故假设三角形不移动,因为不需要高精度所以这里将同一个矩形内的两个三角形都抽象为一个点,坐标为矩形左上角的点,由直角三角形勾股定理可以得到两个点距离等于x轴差值平方和y轴差值平方的和的平方根,得到以下函数

264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
/**
 * @description 设置图形亮度
 * @type {void}
 * @param {Array<Array<number>>} brightArr 要用于存放亮度的已经被清空的数组
 * @param {number} pointX 目标坐标X
 * @param {number} pointY 目标坐标Y
 * @param {number} decaySpeed 每一格光照强度等量衰减
 * @param {number} bright 目标所在的光照强度
*/
function setBright(brightArr, pointX, pointY, decaySpeed, bright) {
    let brightSet;
    for (let i = 0; i < brightArr.length; i++) {
        for (let j = 0; j < brightArr[0].length; j++) {
            brightArr[i][j] = bright - decaySpeed * Math.sqrt((pointX - i) * (pointX - i) + (pointY - j) * (pointY - j)) * 0.7;
            if (brightArr[i][j] < 0) brightArr[i][j] = 0;
        }
    }
}

在计算颜色同时考虑亮度

对之前的计算某时刻颜色的函数进行改造,加入亮度参数

156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
/**
 * @description 按照等速渐变,得到某点在t毫秒时的颜色,返回一个字符串
 * @param {Array<number>} from 来源颜色
 * @param {number} t 单程移动所需时间(ms)
 * @param {number} g 已经经过的时间
 * @param {number} l 额外亮度增益,最大1,默认0
 * @param {Array<number>} to 目标颜色
*/
function getMoveColor(from, to, t, gone, l) {
    let persent = gone / t;
    persent %= 2;
    if (persent > 1) persent = 2 - persent;
    let r = from[0] + (to[0] - from[0]) * persent;
    let g = from[1] + (to[1] - from[1]) * persent;
    let b = from[2] + (to[2] - from[2]) * persent;
    //如果存在亮度
    if (l > 0) {
        let bAdded;
        if (b != 0) {
            bAdded = (255 - b) * l;
            b = (255 - b) * l + b
        }
        if (g != 0) {
            //g = (255 - g) * l + g
            g += bAdded;
            if (g > 255) g = 255
        }
    }
    r = parseInt(r);
    g = parseInt(g);
    b = parseInt(b);
 
    return "rgb(" + r + "," + g + "," + b + ")";
}

大功告成

在以上步骤全部完成后,重新绘图,查看最终效果

最终效果浏览 (点击查看原图)

以上,绘制完成!

源码下载:本文最终效果源码

  1. 头像 小松子说道:

    最近要做一个vue的界面参考一下,请问移动速度是哪个函数在控制

    1. 头像 手柄君说道:

      好久没弄vue了,帮不上忙抱歉了

  2. 韩大妈 韩大妈说道:

    前来学习

    1. 头像 Handle说道:

      惊,韩大妈难道要开始学JS了么

      1. 韩大妈 韩大妈说道:

        其实已经学习了大半年啦=。=

        1. 头像 Handle说道:

          好强!

  3. 头像 九宫格大魔王说道:

    效果很好

来一发吐槽