雨打西窗,再见那年夏天的暴雨⛈️!用js实现(源码+npm)

39,225 阅读10分钟

我正在参加「初夏创意投稿大赛」详情请看:初夏创意投稿大赛

📖阅读本文,你将:

  1. 隔岸观火一场盛夏的暴雨。
  2. 学会用 canvas 开发动画特效的思路和技巧。

一、青春暴雨

每个人的青春,都有几场盛夏里难以忘怀的暴雨。

有的人在暴雨中失意;有的人在暴雨中相拥;有的人在躲雨的屋檐下相遇;有的人只是坐在窗边听雨声、看雨落,一眨眼便是很多年;

我也还记得许多场雨。

18岁,高考最后一门英语结束,走出考场大雨倾盆,同窗们不及告别,匆匆避雨归家,踏上各自的人生;

21岁,十一假期,和大学室友去打工兼职,暴雨倾城,没有伞的我们举着纸壳子走了几里路搭乘公交;

23岁,下班路上电动车没电了,推着电动车在乌泱乌泱的雨幕里艰难跋涉归家。

喜怒悲欢,皆是青春。
……

暴雨常有,青春的经历却不会再有,那么我们有没有办法在屏幕上完成一次 “暴雨的演绎” ,让你和盛夏的暴雨再度重逢呢?

当然,没问题。

我甚至为此专门写了一个 “开源” 作品,让我们一一品味雨天的感觉:

(感谢这张照片的提供者:翊君 大佬,他是一位有才情、有技术的后端大佬)

不用害怕,这个效果的实现原理非常容易,你可以轻易 get 到,我会手把手教你理解其中的关隘,也可以来和我一起维护这个小小的开源作品;

二、代码组织、结构

首先,我们的目标不是写一个 “小 demo”,毕竟我已经写了太多 demo,需要对自己有一些更高的要求了。

所以,我们需要是一个 可发布可调试 的组件库,其代码组织如下:

│  package.json
│  rollup.config.js
├─dist
├─examples
│  └─base
│          index.html
└─src
    │  Drop.ts
    │  DropKeeper.ts
    │  index.ts
    │  RainyCanvas.ts
    │  TimeKeeper.ts
    │  types.ts
    └─helpers
            css.ts
            drop.ts
            element.ts
            math.ts

代码结构比较常规:

  • src 是实现代码的目录;
  • examples 是存放各种示例的目录;
  • dist 目录是构建物目录;
  • rollup.config.js 是本工程构建工具 rollup 的配置文件;

当你想进行代码调试时,只需要执行命令 yarn dev 或者 npm run dev,根据提示访问调试页面了:

关于工程的搭建、构建、发布,内容太多,不在本文细数,后续再专门发文解释;

Ok,现在工程搭建好了,让我们来看看 “雨打西窗” 的效果究竟是怎么实现的。

三、定位、图层、API 设计

为了让开发出来的工具简单好用,一眼就懂,我初步如此设计本库的 api:

import rainy from 'rainy'

// 参数一:div元素的ID 或者 元素本身;
// 参数二:图片的url
rainy('ElementId', 'https://pic.xxx.xxx/a.jpg')

// 可选参数三:各类配置项
rainy('ElementId', 'https://pic.xxx.xxx/a.jpg', { ... })

按照 API 的设计,我们现在有了一个 div 元素,然后我们就需要考虑需要几层画布(canvas),以及如何定位它们了;

我的设计是在 div 内放置两层画布:

  • div 需要具备宽、高;如果不是 position:relativeposition:fixed 就会被赋予 position:relative属性;

  • 背景画布:专门绘制模糊的背景图;通过 absolute 占满 div

  • 玻璃画布:专门绘制玻璃上滚动的水珠;通过 absolute 占满 div;通过 z-index 比背景高出一层;

四、绘制背景、添加模糊

暴雨天,窗户上凝起了一层薄雾;

通过如下代码,我们可以将一张图片清晰地画在画布上:

  const ctx = getCtx(this.backgroundCanvas);
  ctx.drawImage(this.img, 0, 0, width, height)

实现如下效果:

但这显然不符合 “暴雨中窗户起雾” 的效果,因此,我们还需要给它加上一层淡淡的 “高斯模糊”:

ctx.filter = `blur(10px)`

加完之后,效果立竿见影:

五、水滴?视觉把戏罢了!

水滴的绘制,可能是本文最让人一开始摸不着头脑的地方了。

绘制一个看起来像那么回事的水滴,其实只需要一点简单的 初中物理知识 : 凸透镜成像!

窗户上的水滴,就是一个天然的凸透镜。

因此,当我们想画水滴时,其实只需要以下三个步骤:

5.1 首先需要画一个倒影

初中物理说:“只要你身处凸透镜的焦距以外,它的成像就是倒影。”

而水滴通常很饱满,焦距就很短,所以你看到的水滴上的图案,通常就是窗外风景的倒影;

代码如下:

// 因为水滴很多,所以需要压缩一下图片的尺寸
canvasEl.width = width * this.options.minification
canvasEl.height = height * this.options.minification
// 调整中心
ctx.translate(canvasEl.width/2, canvasEl.height/2)
// 旋转画布
ctx.rotate(Math.PI)
// 绘制图片
ctx.drawImage(this.img, -canvasEl.width/2, -canvasEl.height/2, canvasEl.width, canvasEl.height)

5.2 将倒影剪裁成一个圆形

水滴是圆的,至少看起来是。

因此,我们不能直接放一个倒影给用户看,至少我们得将它剪裁成水滴的形状;

因此,我将水滴对象 Drop 赋予三个基础属性,即:

type Drop {
  x = number; // 圆心横坐标
  y = number; // 圆心纵坐标
  r = number; // 水滴半径
}

代码如下:

draw() {
  // 记录当前状态
  ctx.save()
  // 开始路径
  ctx.beginPath()
  // 画个圆
  ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2, true)
  // 结束路径
  ctx.closePath()
  // 剪裁
  ctx.clip()
  // 绘制倒影图片
  ctx.drawImage(reflection, 0, 0, width, height)
  // 还原画布状态
  ctx.restore()
}

这样就完成水滴绘制了吗?

很可惜,并不是如此,并不是每个水滴中呈现的 “影像” 都完全一致的,在水滴滚落的过程中,我们可以清晰地观察到其中的影响再随着滚动发生着变换;身处玻璃不同位置的水滴,也总是倒映着不同的图案;

5.3 光影变换,方显世界

让我们按 5.2 节的思路画三颗水珠:

虽然它们已经很像是 “水珠” 了,但每颗水珠都显示一模一样的风景并不太符合我们平时观察风景时的现象;

“真实的表现” 应该是如右图所示,每颗水滴都只倒影风景的一部分,这样才更显真实。

那么,这要怎么实现呢?

分析一下,屏幕上水滴的移动范围,和 “水滴视野” 的移动范围是存在差异的:

于是,可以写出如下代码:

  // 当前水滴到边沿的最小距离
  const minDistanceToEdge = Math.min(this.x, canvasWidth - this.x, this.y, canvasHeigh - this.y)
  // 画布短边长度
  const shorterEdge = Math.min(canvasHeigh, canvasWidth)
  // 视野半径 (最小半径和最大半径可以通过传参比例调整)
  const visionRadian = Math.min(Math.max(minDistanceToEdge, shorterEdge * this.minReflectionRatio / 2), shorterEdge * this.maxReflectionRatio / 2)
  // 视野开始位置 x 坐标
  const sx = canvasWidth - 2 * visionRadian - (canvasWidth - 2 * visionRadian) * this.x / canvasWidth
  // 视野开始位置 y 坐标
  const sy = canvasHeigh - 2 * visionRadian - (canvasHeigh - 2 * visionRadian) * this.y / canvasHeigh
  // 乘以倒影的缩放系数
  return [sx, sy, visionRadian, visionRadian].map(t => t * this.environment.minification)

这样,就能实现如下效果了:

六、让水滴动起来!

静止的画面是没有灵魂的,否则不如去拍照;

水不会一直凝固在玻璃上,重力会拽着它们奔向地面,因此大部分水滴都会选择合适的时候向下滚落,沿途将碰到的水滴据为己有,一路向下。

这种情况下,我们就必须补一个动画常识了:

Canvas 动画绘制按帧更新,绝大多数时候,都是通过以下函数:

window.requestAnimationFrame(fn)

一般而言,此函数的使用方法如下:

const render = () => {
  // 在此处做一些动画渲染
  renderSomeThing()
  // 递归调用
  requestAnimationFrame(render)
}
render()

对于我们的动画也是如此思路:

const render = () => {
  // 绘制此水珠。
  drop.draw()
  // 递归调用
  requestAnimationFrame(render)
}
render()

然后,我们使用 “补间动画库” 来完成其 xy 轴数值的变换,就能完成水滴的绘制了。

import gsap from "gsap";

/**
 * @param speedX 水平速度
 * @param speedY 竖直速度
 * @param duration 运动时间(秒)
 * 通过此方法给水滴赋予速度、运动时间
 * */
setSpeed(speedX: number, speedY: number, duration: number) {
    this.speedX = speedX;
    this.speedY = speedY;
    this.moveTarget = [this.x + speedX * duration, this.y + speedY * duration]

    // 动画库,gsap 可太优秀了
    gsap.to(this, {
      duration: duration,
      x: this.moveTarget[0],
      y: this.moveTarget[1],
      onComplete: () => {
        this.speedX = 0
        this.speedY = 0
      }
    })
  }

通过以上思路,就可以成功让水滴运动起来了:

除了主动随机地给水滴赋予速度外,我们还需要考虑到,“过大的水滴” 是不存在的,当水滴大到一定程度,就需要被赋予初始速度进行滚落;

七、残留小水滴

上面图片里,你能够看到水滴在滚动时留下了 “一滴滴” 微小的水滴。

在实际生活中,这是因为玻璃并不光滑和干净,导致水滴残留,而在代码里,我更愿意将其称为 “生崽”。

原理也很简单,当一滴水珠在 “运动时”,每隔一个随机时间段,便在其末梢生成一个小半径的水滴加入页面水滴池即可;

为了体现随机性和合理性,让 “水滴崽” 在 [x - r/2, x + r/2] 的范围内产生;

此代码比较容易,就不赘述;

八、融合!沛然无形、海纳百川

没有两颗水滴能重叠在一起。

当两颗水滴重叠时,它们表面的张力会被破坏,从两颗圆润的水滴变成一颗更加圆润的水滴。

8.1 融合形式

但是,“水滴融合”究竟要怎么实现呢?

假设有水滴A和水滴B相交了:

就有以上三种情况:

  • A吃掉B
  • B吃掉A
  • A和B消失,生成新的水滴C

其实三种思路都没毛病,但我们在实现时往往要选择和业务最贴合的方式,在我们的业务中有一个隐藏条件:“水滴可能正在运动。”

因此,我们可以按这个思路来思考实现:

  1. 如果所有水滴都没运动,那完全可以生成一个新水滴;
  2. 如果一静一动,那就是运动的水滴吃掉静止的水滴,并继续它的运动;
  3. 如果一快一慢都在运动,那应该继承快水滴。(如果做的更细致一点,则应该计算其动量保证动量守恒...那咱们整个运动模型可能就要重新设计了)

8.2 体积变化、圆心变化

先算新水滴的半径:

设:水滴A的半径为r1,水滴B的半径为r2,那么融合后水滴C的半径r3是多少?

r1 * r1 * Math.PI + r2 * r2 * Math.PI = r3 * r3 * Math.PI
// 可得
r3 = Math.sqrt(r1 * r1 + r2 * r2)

再算新圆心:

const gravityRatio = r1 * r1 / (r1 * r1 + r2 * r2)
// 按动量守恒计算,新圆心:
x3 = x1 + ( x2 - x1 ) * ( 1 -  gravityRatio)
y3 = y1 + ( y2 - y1 ) * ( 1 -  gravityRatio)

8.3 效果成型

按照上面简单的计算,就可以完成 “水滴融合” 的炫酷效果啦,看起来是不是还真是像那么一回事呢?🤣

九、在 “码上掘金” 里运行

十、源码

源码地址:github.com/zhangshichu…

npm地址:www.npmjs.com/package/rai…

unpkg:unpkg.com/rainy-windo…

安装:

yarn add rainy-window

使用:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script src="/node_modules/rainy-window/dist/umd/index.js"></script>
  <style>
    .outside-the-window {
      height: 500px;
      width: 600px;
    }
  </style>
</head>
<body>
  <div class="outside-the-window" id="OutsideTheWindow">
  </div>
  <script>
    window.rain('OutsideTheWindow', 'http://pic.zhangshichun.top/pic/20220606-02.jpg')
  
  </script>
</body>
</html>

十一、还需要实现的内容

为了参加活动,比较赶,很多细节还没处理好,包括但不限于:

  • 超出画布的水滴需要被移除;
  • 移动中的水滴应该变形
  • 水滴随机变形
  • 产生小水滴的逻辑需要优化
  • 更多

十二、结束语

我是春哥
大龄前端打工仔,依然在努力学习。
我的目标是给大家分享最实用、最有用的知识点,希望大家都可以早早下班,并可以飞速完成工作,淡定摸鱼🐟。

你可以在公众号里找到我:前端要摸鱼