本文从动效这一主题入手,主要讲解Canvas通过利用景深和位移变化实现景深动画效果的实现方案,旨在帮助大家开拓更多的动效实现方法。

背景

最近去拍了一套照片,后期和摄影师选片聊天的时候对光圈,镜头各种很感兴趣,学习了一个新词汇——景深,恰巧又在掘金看到有博主对前段时间哔哩哔哩的首焦banner的一个研究,发现是用几张图片配合 CSS 实现了景深动效,于是百度找了一些资料,决定用Canvas实现一个简单的景深动效。

景深效果原理

在聊景深之前,我们先来聊一聊另外一个比较好理解的词:背景虚化。如果你不知道景深是什么,想必也听说过背景虚化这个词。比如拍摄人像的时候,人像的部分是清楚的,而人像后面的背景是虚的,这样人像就更加凸显出来,这样的效果就是背景虚化。

背景虚化其实就是浅景深的表现。那么到底什么是景深呢?

景深(DOF),是指在摄影机镜头或其他成像器前沿能够取得清晰图像的成像所测定的被摄物体前后距离范围。

我们以现实中相机为例,相机在成像的时候,存在一个合焦面,只有处于合焦面上的物体成像是最清晰的,而合焦面之外的平面所成的像,是模糊的。

现实世界中的一点在照片上成的像,并不是理想的一个点,而是一个光斑。处在合焦面上的点,在相机上所成的像,可认为是一个理想的点。而不在合焦面上的点,所成的像是一个光斑。光斑的大小称为circle of confusion(coc) 。

离合焦面越远,光斑越大,所以将相机对着近处物体对焦,越远的物体越模糊;光圈越大,光斑也越大,所以用大光圈更容易拍出景深效果。

景深原理

比如上面两张示意图,图片中清晰的部分就是景深。在上面的那张图里,景深较浅,我们用narrow表示,下面的这张图里,景深较大,我们用large表示。无论是浅景深,还是大景深,都是画面中清晰的那一步分。

实现思路

首先我们先来看下这个banner图的整体效果:

效果图

整张图片大致可以分为三层:

  1. 背景一层(图层一)
  2. 趴着的小女孩一层(图层二)
  3. 前面叶子上坐着的小女孩一层(图层三)

如下图所示:

三层

将三层图片按照坐标空间系分为三部分,如下图所示,前后两个图层(Blurred)先渲染为模糊区域,中间图层(Focused)渲染为清晰区域,之后可以通过监听鼠标事件调整三个图层之间的景深效果,例如鼠标向右移动,图层三变清晰,后面两个图层调整为背景虚化,鼠标向左移动,图层一变清晰,前面两个图层调整为虚化效果。

原理图

代码实现

因为最近在学习react,所以本次决定用react框架,脚手架选择了开箱即用的小火箭 🚀 Rocketact,一通操作后,完成了项目的初始化,如下图所示:

项目初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── README.md
├── package.json
├── public
│ └── favicon.ico
└── src
├── pages
│ └── app.html
│ └── app.scss
│ └── app.tsx
├── components
│ └── banner
├── styles
│ └── reset.scss
│ └── i
├── utils
│ └── types.ts

完成项目搭建后,就可以愉快的开始我们的撸码工作了😁。

首先我们创建一个banner组件,将资源都引入到组件中:

1
2
3
import bgImage from '../../styles/i/bg.png'
import girl from '../../styles/i/girl-open-eye.png'
...

然后在render()中创建canvas标签,并对每一个canvas设置唯一的ref,方便后面操作每一层图层,这里使用 <img> 引入背景图, 是为了用于撑开父级 <div> 以实现 <canvas> 大小的响应式。

1
2
3
4
5
6
7
8
9
10
11
12
13
public render() {
return (
<div className="banner-wrap" ref={this.fullBox}>
<img src={bannerImage} alt="" className="img-tuodi"/>
<canvas ref={this.state.canvasList.bg} className="banner-bg canvas-bd"></canvas>
<canvas ref={this.state.canvasList.girl} className="banner-girl canvas-bd"></canvas>
<canvas ref={this.state.canvasList.land} className="banner-land canvas-bd"></canvas>
<canvas ref={this.state.canvasList.ground} className="banner-ground canvas-bd"></canvas>
<canvas ref={this.state.canvasList.littleGirl} className="banner-littleGirl canvas-bd"></canvas>
<canvas ref={this.state.canvasList.grass} className="banner-grass canvas-bd"></canvas>
</div>
);
}

然后我们将引入的静态资源图片绘制到canvas元素中,这里需要注意的是多张图片的排列顺序问题,避免因为图片大小的不一样,导致排在前面的图层后被load然后绘制在下面这一现象。

我的方法就是在 componentWillMount 生命周期先将所有静态资源都load,之后在 componentDidMount 生命周期再去绘制,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
buildImage = (src: string) => {
return new Promise((resolve) => {
const image = new Image()
image.onload = () => resolve(image)
image.src = src
})
}
componentWillMount() {
let imagesSrc = {
bg: bgImage,
girl: girl,
...
}
Object.entries(imagesSrc).map(([key,value]) => {
this.buildImage(value).then((img) => {
imagesSrc[key] = img
this.setState({
images : imagesSrc
})
})
})
};
componentDidMount() {
// // 延时保证canvas获取宽高准确
setTimeout(() => {
if(this.state.images.bg) {
this.draw(this.state.images.bg, config.bg,this.state.canvasList.bg)
}
}, 100);
}

此时我们的页面上就可以看到一个完整的背景图,但其实他是多层canvas图片叠加之后的效果:

效果图

接下来就最重要的动效添加部分了,敲黑板,重点来喽~

监听鼠标事件来实现景深效果,根据鼠标移动时位置与初始位置的距离比例计算景深,考虑到目前图层都是实的,所以初始化的时候我对第一个图层和背景图层进行了虚化,突出中间图层,效果如下:

初始化

同时这里的女孩有一个眨眼睛的动效,突出中间图层部分,主要实现方式是通过眨眼睛的几个状态图进行叠加显示, 代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
wink = async() => {
await new Promise((r) => setTimeout(r, 50))
this.state.images.girlClosingEye &&
this.draw(this.state.images.girlClosingEye, config.girl,this.state.canvasList.girl)
await new Promise((r) => setTimeout(r, 50))
this.state.images.girlCloseEye &&
this.draw(this.state.images.girlCloseEye, config.girl,this.state.canvasList.girl)
await new Promise((r) => setTimeout(r, 50))
this.state.images.girlOpeningEye &&
this.draw(this.state.images.girlOpeningEye, config.girl,this.state.canvasList.girl)
await new Promise((r) => setTimeout(r, 50))
this.state.images.girl && this.draw(this.state.images.girl, config.girl,this.state.canvasList.girl)
setTimeout(this.wink, 4800)
}

componentDidMount() {
setTimeout(this.wink, 4800)
}

眨眼睛

在以上初始化的基础上,监听鼠标事件来处理图层的虚实,主要监听三个事件:

  • mouseenter 获取鼠标进入的x值
  • mousemove 获取鼠标移动的x值
  • mouseout 离开恢复初始化状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 初始化一个鼠标进入的坐标
let enterPoint = {}

this.fullBox.current.addEventListener('mouseenter', (e: { clientX: number; }) => {
const { width } = this.fullBox.current.getBoundingClientRect()
enterPoint.x = e.clientX
enterPoint.w = width
})

// 计算当前位置与初始位置的距离的比例,设置为虚化值
this.fullBox.current.addEventListener('mousemove', (e: { clientX: number; }) => {
const v = e.clientX - enterPoint.x
const ratio = v / enterPoint.w
requestAnimationFrame(() => this.renderCanvas(ratio))
})

// 鼠标离开时, 缓慢恢复至初始化状态
this.fullBox.current.addEventListener('mouseout', (e: { clientX: number; }) => {
const v = e.clientX - enterPoint.x
let ratio = v / enterPoint.w
const gap = 0.08 * (ratio < 0 ? 1 : -1)

requestAnimationFrame(() => this.tick(ratio,gap))
})

最后让我们来看下最终的实现效果:

最终效果

文章到这里基本完成了,以上只选取部分有代表性的代码片段, 想要看完整代码及具体效果的同学,可以去这里查看
传送门:景深动效

总结

本篇文章从拍照时经常会提到的景深效果原理出发,然后将这一效果运用的web前端开发中,帮助小伙伴们开阔思路,希望对大家以后开发动效有所帮助,谢谢大家的阅读,有问题可以随时与我联系。