如今各大视频网站都有弹幕功能,貌似不存在没有弹幕的视频直播网站。弹幕元素比起留言板等交互性和实时性更高,深受广大基友们喜欢。
然后,我就在各大视频网站假装观看视频的同时,悄悄地按下了F12,想一探究竟。了解发现,目前弹幕的主要实现有两种方式:
一说到动画,大家第一时间能想到的就是Canvas。使用Canvas能很方便地绘制动画,并且获得非常不错的性能,目前前端不少动画都是通过Canvas去做。不过对于基于Canvas的动画而言,最大的问题就是“交互性”上面。
如果用HTML+CSS的方式实现,我们可以很简单地通过监听原生DOM事件去知道哪一条弹幕与用户鼠标发生了交互。但是通过Canvas,我们只能通过监听画布的事件,然后做一堆遍历计算坐标的骚操作去确定是哪一条弹幕。从鹅厂的视频网站可以看到,他们的弹幕是可交互的,所以他们使用了HTML+CSS的实现方式;而B站的弹幕是非交互的,它提供Canvas和HTML+CSS可选,默认是前者。
虽然功能性上两者的实现会有点差异,但弹幕的基本原理都是一样。
我们来看看B站的弹幕具体是什么样子的:
从上图可以看到,弹幕是很清晰地分成了一行一行,我把它们称为“轨道”。每一个弹幕都只在轨道上从右往左移动,不会越界。那么,要实现弹幕功能,首先我们必须把弹幕分成若干个轨道,然后再在合适的时间把弹幕“塞”进去让它平移。
每一个轨道会有两个属性:
barrages: T[] = []
offset: number = 0
barrages
为一个弹幕数组,offset
则是已占据的宽度。offset
用于滚动弹幕时,弹幕轨道添加弹幕前判断最佳轨道;当弹幕类型时固定时无作用。barrages
存放当前轨道上可现实的弹幕实例。
每一个轨道实例管理自己轨道中的数组,主要进行进行增、删、重置以及更新offest
的操作。
push(...items: T[]) {
this.barrages.push(...items)
}
每一次添加新弹幕都把弹幕推入弹幕数组末尾即可。
可以删除指定位置的弹幕:
remove(index: number) {
if (index < 0 || index >= this.barrages.length) {
return
}
this.barrages.splice(index, 1)
}
同时,一般情况下,我们都是按数组的顺序渲染弹幕的。也就是说,每一次重新渲染,更新完弹幕位置之后,首先移除画布的弹幕必定是数组的第一个元素,因此,为了更方便移除弹幕数组顶部元素,轨道还拥有一个removeTop
方法:
removeTop() {
this.barrages.shift()
}
重置应用的场景有不少,比如用户拖动进度条后,当前画布上的弹幕已经是脱离时间线了,因此会重新渲染弹幕。这时候就需要轨道清空弹幕数组并重置offset
。
reset() {
this.barrages = []
this.offset = 0
}
随着每一帧弹幕的渲染,数组中的弹幕元素不断地向左移动,此时轨道右方的空间越来越大。在选择对应的轨道推入弹幕时,我们需要寻找剩余空间最大的轨道推入。因此,在执行完每一次渲染后,都需要轨道更新自己的轨道剩余空间。(滚动弹幕时)
updateOffset() {
const endBarrage = this.barrages[this.barrages.length - 1]
if (endBarrage && isScrollBarrage(endBarrage)) {
const { speed } = endBarrage
this.offset -= speed
}
}
而实际上,剩余空间 = 轨道宽度 - offset
。
interface TrackForEachHandler<T extends BarrageObject> {
(track: T, index: number, array: T[]): void
}
export default class BarrageTrack<T extends BarrageObject> {
barrages: T[] = []
offset: number = 0
forEach(handler: TrackForEachHandler<T>) {
for (let i = 0; i < this.barrages.length; ++i) {
handler(this.barrages[i], i, this.barrages)
}
}
reset() {
this.barrages = []
this.offset = 0
}
push(...items: T[]) {
this.barrages.push(...items)
}
removeTop() {
this.barrages.shift()
}
remove(index: number) {
if (index < 0 || index >= this.barrages.length) {
return
}
this.barrages.splice(index, 1)
}
updateOffset() {
const endBarrage = this.barrages[this.barrages.length - 1]
if (endBarrage && isScrollBarrage(endBarrage)) {
const { speed } = endBarrage
this.offset -= speed
}
}
}
轨道管理轨道内弹幕的增、删操作,但不负责渲染。而我们知道,一个画布上有若干个轨道;同时,弹幕又分为滚动弹幕、顶部固定弹幕、底部固定弹幕。也就是说,滚动弹幕轨道上可能还叠加着固定弹幕轨道。如果更好地管理多个轨道的工作呢?答案就是“指挥官”。
弹幕主要分三种:滚动弹幕、顶部固定弹幕、底部固定弹幕。其中每一个类型的弹幕中有若干个轨道。我们把不同类型的弹幕轨道交给不同类型的指挥官。因此,我们获得了三种指挥官:滚动弹幕指挥官、顶部固定弹幕指挥官、底部固定弹幕指挥官。
指挥官的作用是管理自己的轨道渲染问题,因此核心工作从render
方法开始:
render(): void {
this._extractBarrage()
const ctx = this.ctx
const trackHeight = this.trackHeight
this.forEach((track: Track<ScrollBarrageObject>, trackIndex) => {
let removeTop = false
track.forEach((barrage, barrageIndex) => {
const { color, text, offset, speed, width, size } = barrage
ctx.fillStyle = color
ctx.font = `${size}px 'Microsoft Yahei'`
ctx.fillText(text, offset, (trackIndex + 1) * trackHeight)
barrage.offset -= speed
if (barrageIndex === 0 && barrage.offset < 0 && Math.abs(barrage.offset) >= width) {
removeTop = true
}
})
track.updateOffset()
if (removeTop) {
track.removeTop()
}
})
}
整个render
函数主要的步骤有两个:
每一个指挥官都有一个等待队列waitingQueue
,里面存在尚未渲染的弹幕。每次调用render
函数时,都首先尽可能地将等待队列中的弹幕添加到合适的轨道。而这个过程,是通过this._extractBarrage
实现的:
_extractBarrage(): void {
let isIntered: boolean
for (let i = 0; i < this.waitingQueue.length; ) {
isIntered = this.add(this.waitingQueue[i])
if (!isIntered) {
break
}
this.waitingQueue.shift()
}
}
_extractBarrage
方法会从头开始遍历等待队列,依次按顺序执行this.add
方法。当this.add
返回True
时,则说明该弹幕成功加入到合适的轨道中,否则说明目前没有合适的轨道。因此,只要有一次this.add
方法返回False
,则表示剩下的弹幕都无没有合适的轨道,提前结束计算。
因此,this.add
的作用是添加弹幕到合适的轨道。但是,this.add
为了实现正确的添加功能,还需要完成这些逻辑:为弹幕寻找合适的轨道、标准化弹幕格式。
add
方法是指挥官抽象类的抽象方法,具体实现由不同类型的指挥官类来实现,这里以滚动弹幕为例:
add(barrage: ScrollBarrageObject): boolean {
const trackId = this._findTrack()
if (trackId === -1) {
return false
}
const track = this.tracks[trackId]
const trackOffset = track.offset
const trackWidth = this.trackWidth
let speed: number
if (isEmptyArray(track.barrages)) {
speed = this._defaultSpeed * this._randomSpeed
} else {
const { speed: preSpeed } = getArrayRight<ScrollBarrageObject>(track.barrages)
speed = (trackWidth * preSpeed) / trackOffset
}
speed = Math.min(speed, this._defaultSpeed * 2)
const normalizedBarrage = Object.assign({}, barrage, {
offset: trackWidth,
speed
})
track.push(normalizedBarrage)
track.offset = trackWidth + barrage.width * 1.2
return true
}
从B站、腾讯视频等网站可以看到,弹幕一般都是从上往下填充,也就是上面的弹幕先多起来,再往下填充。具体有没有用什么算法我这里没有很深入去了解,只是简单用了一个判断方法:从上往下寻找,只要找到空位就行。
const trackId = this._findTrack()
if (trackId === -1) {
return false
}
如果找到了合适的轨道,那么就继续执行下面的逻辑;否则,直接返回False
。
以滚动弹幕为例,弹幕除了文本、颜色、大小外,还需要弹幕的平移速度和偏移量,这样我们才能够方便地渲染弹幕,这时候就要对传入的弹幕进行标准化。对于滚动弹幕来说,标准化主要进行的就是计算弹幕的速度。
对于弹幕的速度,这就涉及到我们的初中数学知识:追及问题。
“在长S的轨道上,弹幕A以x的速度匀速前进,当弹幕A距离终点T时,弹幕B从起点出发,以y的速度匀速前进。请问,如果弹幕A和弹幕B同时到达终点,那么弹幕B的速度应该是多少?”
通过小学生式的数学计算后,可以得出:y=Sx/(S-K)
其中,S为轨道宽度,是已知的;K为弹幕A和弹幕B之间的距离,通过弹幕宽度-偏移量得,而偏移量已知,因此,K也是已知的;x为弹幕A的速度,也是已知的。那么,我们就可以很轻松求出弹幕B的“非理想最大速度”了。
为什么是“非理想最大速度”?因为考虑到如果弹幕A和弹幕B之间相距很远,就可能会造成弹幕B的速度过快的问题。直接影响就是弹幕内容没看到,弹幕飞一下就过去了,整个体验非常不好。那么,这里就设定了一个理想最大速度。
为了照顾人类的眼球,我这里把理想最大速度限制在了平均速度的两倍(通过轨道宽度和弹幕生存时间求得)。同时,对于第一个弹幕而言,它是不存在追及问题的,因此,它的速度也是等于平均速度。
let speed: number
if (isEmptyArray(track.barrages)) {
speed = this._defaultSpeed * this._randomSpeed
} else {
const { speed: preSpeed } = getArrayRight<ScrollBarrageObject>(track.barrages)
speed = (trackWidth * preSpeed) / trackOffset
}
speed = Math.min(speed, this._defaultSpeed * 2)
const normalizedBarrage = Object.assign({}, barrage, {
offset: trackWidth,
speed
})
这样的话,我们就求得弹幕的速度了,同时也得到了标准化弹幕。最后,放入到指定轨道中等待渲染即可。
无论是指挥官类还是轨道类,这里都手动实现了forEach
方法便于遍历。基于Canvas的弹幕需要用对应的context
去画图,并且在初始化ABarrage类时,我们已经传入了一些弹幕的配置信息,比如轨道高度、弹幕时间、默认大小、默认颜色等,我们把配置与每一个弹幕合并后,得到最终的配置,然后调用context.fillText
方法进行绘制。
render(): void {
this._extractBarrage()
const ctx = this.ctx
const trackHeight = this.trackHeight
this.forEach((track: Track<ScrollBarrageObject>, trackIndex) => {
let removeTop = false
track.forEach((barrage, barrageIndex) => {
const { color, text, offset, speed, width, size } = barrage
ctx.fillStyle = color
ctx.font = `${size}px 'Microsoft Yahei'`
ctx.fillText(text, offset, (trackIndex + 1) * trackHeight)
barrage.offset -= speed
if (barrageIndex === 0 && barrage.offset < 0 && Math.abs(barrage.offset) >= width) {
removeTop = true
}
})
track.updateOffset()
if (removeTop) {
track.removeTop()
}
})
}
考虑到每一帧后弹幕的偏移量都会减少,因此还需要执行barrage.offset -= speed
这一句进行偏移量更新。
除此之外,每一帧的渲染结束后,都需要去判断是否有弹幕已经完全超出的画布的范围,如果是,则将它从轨道中弹出。
if (removeTop) {
track.removeTop()
}
至今日,弹幕已经流行于各大视频直播网站,目前弹幕的实现主要通过Canvas或者HTML+CSS3。笔者在这里只是介绍弹幕的思路,以Canvas的实现为核心。实际上,HTML+CSS3的实现也仅仅在渲染方式上略有不同,Canvas是通过画笔去绘制,而HTML则是通过操作DOM和transform
样式去控制。对于交互式的弹幕,借助于DOM事件,使用HTML的实现会更加出彩。
最后,如果对完整的实现有兴趣的话,笔者这里安利自己写的一个弹幕库:ABarrage,里面实现了Canvas和HTML+CSS3两种渲染模式,并且是基于纯TypeScript编写的,没有任何第三方依赖。如果对你有帮助,希望能给个Star或者点个赞,作为对笔者这个菜逼的鼓励~感谢。
ABarrage 的Github地址:https://github.com/logcas/a-barrage
ABarrage Demo: https://logcas.github.io/a-barrage/example/