《那朵花》ED 花雨效果实现
特别喜欢《未闻花名》,ED 听了很久,对片尾的花雨效果映像特别深刻。所以试着实现一个 5 毛版本的花雨效果。
可能还有很多小伙伴还没有看过《花名》,这里也小小的安利一下,动画里面的花雨效果是这样的:《那朵花》片尾花雨效果。
大体就是花朵从上往下落,然后有个过渡静止、缩小后改变颜色后反向运动。


然后这个是我们 5 毛实现的版本:5 毛花雨效果-codepen 源码。
让我们先来画一朵花
这个动画的核心就是这些纷纷攘攘的花朵,先来看一下动画中的花朵是怎样的:

动画场景中都是这种 5 个花瓣的花朵,花朵的原型是勿忘我,常见的还是蓝色居多。
动效果要用到大量不同颜色形状的花朵,所以使用背景图片肯定是不行的。花瓣的图案还是比较简单的,用 AI 或者 Sketch 的钢笔工具可以很方便描出花朵的路径然后导出到 SVG 中使用,但 SVG 并不适合大批量的绘制图形,性能是个问题。所以这里我们使用 canvas 来实现我们想要的效果。
直接用 canvas 画一朵花还是很没头绪的,但我们可以参考与花朵形状类似的五角星:


比较上下两个图形,花朵的形状其实就是由五角星过渡过来的,只是中间多了一层控制点将尖角边变成了弧线而已。所以我们只要取到三个圆弧上的绘制点,将中间圆上的点作为二次贝塞尔曲线的控制点,依次连接曲线就能花朵的形状了。
圆上点的坐标通过简单的几何计算就能得到了:
// 获取指定圆心、角度、半径圆上的点坐标
function getPoint (ox, oy, ang, radius) {
const rad = Math.PI * ang / 180
return {
x: ox + radius * Math.sin(rad),
y: oy + radius * Math.cos(rad)
}
}
是不是很简单,具体的花朵的绘制可以参考上面的例子。花朵绘制出来了,但是规规整整的没什么花的形韵,所以我们可以稍稍处理一下,给中间圆上的控制点设置一些随机偏移,使得画出的花瓣显自然一点。

最终画出来的效果是这样的:

为花朵加一些动效
花朵的绘制完成了,接下来就可以为花朵加一些动效了,看看还缺些什么:
- 花朵随机出现在画面上,有不同的大小、颜色与透明度
- 每朵花的运动速度都是不一样的
- 花朵下落或者上漂时伴随则向左或者向右移动
- 花朵下落时颜色为灰色调,旋转的方向为顺时针
- 花朵静止反向上飘时颜色转为粉色调,旋转的方向变为逆时针
所以我们需要一个 Flower 的类用生成各种属性不一样的花朵:
function Flower (cw, ch, radius, colors, alpha, vy, vr) {
// 随机出现在 canvas 上
this.x = random() * cw
this.y = random() * ch
this.vy = vy
// -0.5 < vx < 0.5
this.vx = random() * 1 - 0.5
this.vr = vr
this.cw = cw
this.ch = ch
this.alpha = alpha
this.radius = radius
this.color = '#ccc'
// colors[0] 灰色调、colors[1] 粉色调
this.colors = colors
this.count = count
this.rotate = 0
// 1 表示向下运动 0 静止 -1 向上运动
this.vertical = 1
this.points = []
......
}
然后花朵需要利用上面绘制花朵的方法创建出自身的路径进行绘制,还需要一些方法改变花朵的运动方向与颜色:
// 静止过后将 vertical 设置为 1 花朵开始向上反向运动
Flower.prototype.reverse = function reverse () {
this.vertical = -1
}
// 将 vertical 设置为 0 画面静止,改变花朵的颜色
Flower.prototype.zoom = function zoom () {
this.vertical = 0
this.setColor()
}
// 设置花朵的颜色时,花朵刚开始向下落时取的是灰色调的颜色
// 待到画面静止取的是粉色调颜色列表的里面的色彩
Flower.prototype.setColor = function setColor () {
if (this.vertical === 1) {
this.color = this.colors[0]
} else {
this.color = this.colors[1]
}
}
最后是在做动画循环时更新花朵的位置,旋转角度与边界检测。这里的花朵都是进行简单的线性运动,所以动画更新还是比较简单的:
// 动画循环时用于更新花朵的位置与大小、边界检测
Flower.prototype.update = function update () {
// vertical 为 0 时,花朵停止运动,进行缩放
if (!this.vertical && this.scale >= 0.9) {
this.scale *= 0.99
return
}
var halfRadius = this.halfRadius
this.rotate += this.vr * this.vertical
this.x += this.vx * this.vertical
this.y += this.vy * this.vertical
// 花瓣到达边界时重新设置花瓣的位置
if (this.x < -halfRadius || this.x > this.cw + halfRadius) {
this.x = this.x > 0 ? -halfRadius : this.cw + halfRadius
}
if (this.y < -halfRadius || this.y > this.ch + halfRadius) {
this.y = this.y > 0 ? -halfRadius : this.ch + halfRadius
this.x = random() * this.cw + this.halfRadius
}
}
花的类已经实现好了,接着就是构建多个花的实例,将它们绘制到 canvas 上了。
完整代码实现在这里,基本效果算是出来了,随机渲染了 100 朵花:

接下来进行下一步,让我们先来渲染 1000 一个背景层的花朵看看效果如何。机智的你可能已经猜到结果了,就是画面变得巨卡无比。 可以看看渲染 1000 花朵的效果:

这到底是由什么造成的呢?
动画循环方面我们已经使用 requestAnimationFrame 进行优化了,所以这肯定不是性能的瓶颈所在。实际的问题出在 flower.drow 的操作上。 在 requestAnimationFrame 循环更新动画时,每一帧 flower.drow 都会调用 canvas api 进行花朵的重绘,而 canvas 的 api 调用恰巧又是极其占用 CPU 资源的,再加上绘制后 UI 渲染更新,绘制的花朵数量多了画面自然显得卡顿。那有什么方法可以进行优化吗?
答案是肯定的,下面介绍一种常用的 canvas 性能优化方案:离屏渲染。
使用离屏渲染对动画进行优化
既然性能的瓶颈是由于 flower.drow() 重绘造成,那我们是不是可以通过某些方法将花朵的重绘次数将至最低,以减少 canvas 重绘操作呢?或者说我们是不是可以将绘制好的图形缓存起来以重复利用呢?
实际上离屏渲染的实现思路就是利用无界面的 canvas 元素将绘制完成的图案进行缓存,无界面的 canvas 的元素在绘制图案时不需要渲染,所以不会有 UI 渲染的开销。在下一次进行动画更新时直接将缓存好的绘制图案直接输出到目标 canvas 之上,不再进行绘制操作。
首选我们需要为每一朵花添加一个自身的无界面 canvas 元素,用于对绘制的图案进行缓存。并且 canvas 的大小应当与绘制图案的大小相当,这样不会造成资源的浪费。因为在不考虑绘制图案复杂情况下,canvas 的大小越小自然缓存的数据量也就越小,所占的资源也就越少:
function Flower (cw, ch, radius, colors, alpha, vy) {
var cacheCanvas = document.createElement('canvas')
// 这里的 canvas 就是一个长宽为圆直径的正方形,花朵就是绘制在这上面
cacheCanvas.width = radius * 2
cacheCanvas.height = radius * 2
......
this.canva = cacheCanvas
this.ctx = cacheCanvas.getContext('2d')
......
this.cache()
}
// 先在自身的离屏 canvas 缓存绘制出花瓣图案
Flower.prototype.cache = function cache () {
......
this.ctx.drow...
}
......
// 这里不再进行绘制
// 而是使用 context.drawImage 将缓存的 canvas 绘制到需要渲染的 context 上
Flower.prototype.drow = function drow (context) {
context.save()
context.translate(this.x, this.y)
context.rotate(this.rotate)
context.scale(this.scale, this.scale)
context.drawImage(this.canva, -this.radius, -this.radius)
context.restore()
}
Flower 初始化创建一个无界面的 canvas 元素,然后调用 cache 绘制出花朵的图案,这样绘制花朵的就保存在内部的 canvas 上。
cache 现在只负责绘制图案,所以与动画相关的 translate、rotate、scale 变换操作,全都移交给 drow 方法,并在渲染的目标 canvas 的 context 上进行操作。这里需要注意特别注意坐标 translate 变化。
所以现在每次动画循环时,flower 是不进行再绘制操作的,它只是将自身缓存的绘制图案通过目标的 context 的 drawImage 方法输出到渲染的 context 之上。少了 flower 的重绘画,渲染效率自然就就提高了。
这是优化后的花雨效果,渲染了 1000 朵花,不再有使用离屏渲染前卡顿了:

为花雨效果分层
解决完动画的性能问题,继续我们的 5 毛效果,看看还缺少些什么?
细看动画中的效果,整个场景是有明显的分层:
- 背景的层的花朵数量众多、花朵偏小、颜色偏浅而且运动速度比前景层更快
- 前景层的花朵数量较少,花朵偏大、颜色偏深、运动速度较慢
- 中间层的花朵数量比前景数量更多,但远不及背景层,颜色大小与运动速度同理
我们所要做的就是按照这个规则,分别来绘制不同层级的花雨,所以这里我们将引入一个 Layer 类用于管理每层的花雨:
function Layer (options) {
const { ctx, count, size, alpha, vy, vr, colors1, colors2 } = options
const flowers = []
for (let i = 0; i < count; i++) {
const rsize = (random() * (size.max - size.min) + size.min) | 1
const ralpha = random() * (alpha.max - alpha.min) + alpha.min
const rvy = random() * (vy.max - vy.min) + vy.min
const rvr = random() * (vr.max - vr.min) + vr.min
const colors = [getRandomColor(colors1), getRandomColor(colors2)]
flowers.push(new Flower(VW, VH, rsize, colors, ralpha, rvy, rvr))
}
this.context = context
this.flowers = flowers
}
......
花雨将分为背景层、中间层与前景层三层来分别绘制,最终效果:

到此为止一个粗糙的 5 毛花雨特效算是做好了,算是有几分形似吧。
参考资料: