淺談彈幕的設(shè)計(jì)
大廠技術(shù) 堅(jiān)持周更 精選好文
背景
為了創(chuàng)造更好的多媒體體驗(yàn),許多視頻網(wǎng)站都添加了社交機(jī)制,使用戶(hù)可以在媒體時(shí)間軸上的特定點(diǎn)發(fā)布評(píng)論和查看其他人的評(píng)論,其中一種機(jī)制被稱(chēng)為彈幕(dàn mù),在日語(yǔ)中也稱(chēng)為コメント(comment)或者弾幕(danmaku),在播放過(guò)程中,可能會(huì)出現(xiàn)大量評(píng)論和注釋?zhuān)⑶抑苯愉秩驹谝曨l上。彈幕最初是由日本視頻網(wǎng)站Niconico(ニコニコ)引入的。在中國(guó),除了在Bilibili和AcFun等彈幕視頻網(wǎng)站中使用之外,其他主流視頻網(wǎng)站(例如騰訊視頻,愛(ài)奇藝視頻,優(yōu)酷視頻和咪咕視頻)中的視頻播放器也支持彈幕。

形式
單條彈幕的基本模式有三種:
滾動(dòng)彈幕:自右向左滾動(dòng)過(guò)屏幕的彈幕,以自上而下的優(yōu)先度展示。 頂部彈幕:自上而下靜止居中的彈幕、以自上而下的優(yōu)先度展示。 底部彈幕:自下而上靜止居中的彈幕、以自下而上的優(yōu)先度展示。
為什么需要彈幕
從用戶(hù)體驗(yàn)角度出發(fā)——沒(méi)有彈幕之前
在沒(méi)有彈幕之前,我們一般是通過(guò)評(píng)論或者聊天室的方式去進(jìn)行互動(dòng):
(如上,左邊視頻,右邊互動(dòng)區(qū))
傳統(tǒng)互動(dòng)方式帶來(lái)的問(wèn)題是,當(dāng)我們的人眼的關(guān)注點(diǎn)在視頻上時(shí),是沒(méi)辦法進(jìn)行“一眼二用”的,簡(jiǎn)單的來(lái)說(shuō)就是,你沒(méi)辦法讓你的兩顆眼珠子往不同的方向看。這樣帶來(lái)的弊端是,當(dāng)用戶(hù)專(zhuān)注于視頻時(shí),互動(dòng)區(qū)的交互效果是很差的;而當(dāng)用戶(hù)在看互動(dòng)區(qū)的評(píng)論時(shí),又沒(méi)辦法去關(guān)注整件事的主體內(nèi)容,顧此失彼。
(你沒(méi)辦法“一眼二用”)
與此同時(shí),對(duì)于世界上大多數(shù)的人來(lái)說(shuō),自小養(yǎng)成的習(xí)慣就是從左往右的閱讀習(xí)慣。像這種互動(dòng)區(qū)的評(píng)論,通常都是從下往上進(jìn)行自動(dòng)滾動(dòng)的,兩個(gè)方向的合起來(lái)的話整個(gè)文字就形成了一個(gè)傾斜的運(yùn)動(dòng)方向,使得用戶(hù)的閱讀產(chǎn)生了障礙。
(傾斜向上的文字移動(dòng),讓人沒(méi)辦法好好看字)
從用戶(hù)體驗(yàn)角度出發(fā)——彈幕出現(xiàn)之后
彈幕出現(xiàn)后,我們的視角就集中到視頻主體上,當(dāng)彈幕出現(xiàn)時(shí),如果是滾動(dòng)彈幕,那么一般都是從右往左出發(fā),非常適合我們的從左往右的閱讀習(xí)慣,并且,文字的移動(dòng)方向只有一個(gè),不會(huì)給我們的閱讀產(chǎn)生障礙。
除此之外的好處
互動(dòng)性強(qiáng):點(diǎn)播時(shí)讓你覺(jué)得不孤獨(dú)
在觀看視頻網(wǎng)站提供視頻時(shí),觀看者在觀看視頻內(nèi)容過(guò)程中根據(jù)內(nèi)容啟發(fā)會(huì)有一些想法或者吐槽點(diǎn),就想要發(fā)表出來(lái)和更多的人分享,這時(shí)就需要彈幕來(lái)滿(mǎn)足這個(gè)需求。通過(guò)彈幕,可以把同一時(shí)間觀看者的評(píng)論通過(guò)固定方向滾動(dòng)的方式顯示在視頻區(qū)域中,或者靜止的顯示在視頻區(qū)域的頂部或底部,這樣可以增加觀看者和視頻的互動(dòng)特性以及觀看者之間的互動(dòng)。在相同時(shí)刻發(fā)送的彈幕基本上也具有相同的主題。
互動(dòng)性強(qiáng):直播時(shí)的互動(dòng)及時(shí)
彈幕在視頻直播場(chǎng)景中也能夠成為主播與觀眾直接互動(dòng)的方式。比起傳統(tǒng)的實(shí)時(shí)評(píng)論,主播能夠根據(jù)屏幕上彈幕的展現(xiàn)更直觀了解觀眾的需求和反饋,更方便地調(diào)整接下來(lái)的行動(dòng)和處理,也能夠根據(jù)用戶(hù)的輸入進(jìn)行交互操作。
氣氛渲染好:“前方高能”
當(dāng)看一些比較恐怖、懸疑的內(nèi)容時(shí),“前方高能”可能會(huì)避免你心里落下童年陰影[手動(dòng)狗頭]。
彈幕的實(shí)現(xiàn)方式
現(xiàn)如今,從B站、愛(ài)奇藝、騰訊視頻等各大媒體網(wǎng)站上按下 F12 時(shí),很容易發(fā)現(xiàn)是通過(guò) HTML+CSS 的方式實(shí)現(xiàn)的。另外,也有一小部分具備 Canvas 實(shí)現(xiàn)的彈幕,比如之前的B站(不過(guò)在截稿前好像找不到切換按鈕了)。
假如通過(guò) HTML+CSS 實(shí)現(xiàn)
通過(guò) DOM 元素實(shí)現(xiàn)彈幕,前端同學(xué)可以很方便地通過(guò) CSS 修改彈幕樣式。同時(shí),得益于瀏覽器原生的 DOM 事件機(jī)制,借助這個(gè)可以很快捷實(shí)現(xiàn)一系列彈幕交互功能:個(gè)性化、點(diǎn)贊、舉報(bào)等,以滿(mǎn)足產(chǎn)品的各種互動(dòng)需求。很容易看到,目前像騰訊視頻、愛(ài)奇藝等都是通過(guò) DOM 元素實(shí)現(xiàn)彈幕,這是目前主流的實(shí)現(xiàn)方式。
假如通過(guò) Canvas 實(shí)現(xiàn)
Canvas 為動(dòng)畫(huà)而生,但是基于 Canvas 實(shí)現(xiàn)一個(gè)彈幕系統(tǒng),會(huì)比基于 DOM 實(shí)現(xiàn)要復(fù)雜。暫且不說(shuō)對(duì)于大部分前端同學(xué)而言,對(duì) Canvas 的熟悉程度遠(yuǎn)比 DOM 要低,更何況,Canvas 并沒(méi)有一套原生的事件系統(tǒng),這意味著,如果要實(shí)現(xiàn)一些互動(dòng)功能,你必須要自己實(shí)現(xiàn)一套 Canvas 的事件機(jī)制……
彈幕的設(shè)計(jì)
首先是整體設(shè)計(jì),主要是三個(gè)部分:舞臺(tái)、軌道、彈幕池。
舞臺(tái)
舞臺(tái)是整個(gè)彈幕的主控制,它維護(hù)著多個(gè)軌道、一個(gè)等待隊(duì)列、一個(gè)彈幕池。舞臺(tái)要做的事情是控制整個(gè)彈幕的節(jié)奏,當(dāng)每一幀進(jìn)行渲染時(shí),都判斷其中的軌道是否有空位,從等待隊(duì)列中取合適的彈幕送往合適的軌道。
舞臺(tái)的能力可以通過(guò)實(shí)現(xiàn)舞臺(tái)基類(lèi)以及對(duì)應(yīng)的抽象函數(shù),讓具體類(lèi)型的舞臺(tái)去實(shí)現(xiàn)對(duì)應(yīng)的舞臺(tái)邏輯。從而實(shí)現(xiàn)不同渲染能力(Canvas、HTML+CSS)以及不同類(lèi)型(滾動(dòng)、頂部固定、底部固定)的彈幕控制。無(wú)法復(fù)制加載中的內(nèi)容
不管是通過(guò) Canvas 還是 DOM 實(shí)現(xiàn)彈幕,需要的方法都是相似的:添加新彈幕到等待隊(duì)列、尋找合適的軌道、從等待隊(duì)列中抽取彈幕并放入軌道、整體渲染、清空。因此 BaseStage 可以通過(guò)編排抽象方法,讓具體的子類(lèi)去進(jìn)行具體實(shí)現(xiàn)。
export default abstract class BaseStage<T extends BarrageObject> extends EventEmitter {
protected trackWidth: number
protected trackHeight: number
protected duration: number
protected maxTrack: number
protected tracks: Track<T>[] = []
waitingQueue: T[] = []
// 添加彈幕到等待隊(duì)列
abstract add(barrage: T): boolean
// 尋找合適的軌道
abstract _findTrack(): number
// 從等待隊(duì)列中抽取彈幕并放入軌道
abstract _extractBarrage(): void
// 渲染函數(shù)
abstract render(): void
// 清空
abstract reset(): void
}
Canvas 版本
比如,Canvas的舞臺(tái)基類(lèi)需要傳入Canvas元素,獲取Context。最后通過(guò)實(shí)現(xiàn) BaseStage 的抽象方法實(shí)現(xiàn)具體的邏輯。
export default abstract class BaseCanvasStage<T extends BarrageObject> extends BaseStage<
T
> {
protected canvas: HTMLCanvasElement
protected ctx: CanvasRenderingContext2D
constructor(canvas: HTMLCanvasElement, config: Config) {
super(config)
this.canvas = canvas
this.ctx = canvas.getContext('2d')!
}
}
HTML + CSS 版本
而對(duì)于HTML+CSS的實(shí)現(xiàn),就需要維護(hù)一個(gè)彈幕池domPool、彈幕實(shí)例與DOM的映射關(guān)系(objToElm、elmToObj)以及一些必要的事件處理方法(_mouseMoveEventHandler 、_mouseClickEventHandler)。
export default abstract class BaseCssStage<T extends BarrageObject> extends BaseStage<T> {
el: HTMLDivElement
objToElm: WeakMap<T, HTMLElement> = new WeakMap()
elmToObj: WeakMap<HTMLElement, T> = new WeakMap()
freezeBarrage: T | null = null
domPool: Array<HTMLElement> = []
constructor(el: HTMLDivElement, config: Config) {
super(config)
this.el = el
const wrapper = config.wrapper
if (wrapper && config.interactive) {
wrapper.addEventListener('mousemove', this._mouseMoveEventHandler.bind(this))
wrapper.addEventListener('click', this._mouseClickEventHandler.bind(this))
}
}
createBarrage(text: string, color: string, fontSize: string, left: string) {
if (this.domPool.length) {
const el = this.domPool.pop()
return _createBarrage(text, color, fontSize, left, el)
} else {
return _createBarrage(text, color, fontSize, left)
}
}
removeElement(target: HTMLElement) {
if (this.domPool.length < this.poolSize) {
this.domPool.push(target)
return
}
this.el.removeChild(target)
}
_mouseMoveEventHandler(e: Event) {
const target = e.target
if (!target) {
return
}
const newFreezeBarrage = this.elmToObj.get(target as HTMLElement)
const oldFreezeBarrage = this.freezeBarrage
if (newFreezeBarrage === oldFreezeBarrage) {
return
}
this.freezeBarrage = null
if (newFreezeBarrage) {
this.freezeBarrage = newFreezeBarrage
newFreezeBarrage.freeze = true
setHoverStyle(target as HTMLElement)
this.$emit('hover', newFreezeBarrage, target as HTMLElement)
}
if (oldFreezeBarrage) {
oldFreezeBarrage.freeze = false
const oldFreezeElm = this.objToElm.get(oldFreezeBarrage)
oldFreezeElm && setBlurStyle(oldFreezeElm)
this.$emit('blur', oldFreezeBarrage, oldFreezeElm)
}
}
_mouseClickEventHandler(e: Event) {
const target = e.target
const barrageObject = this.elmToObj.get(target as HTMLElement)
if (barrageObject) {
this.$emit('click', barrageObject, target)
}
}
reset() {
this.forEach(track => {
track.forEach(barrage => {
const el = this.objToElm.get(barrage)
if (!el) {
return
}
this.removeElement(el)
})
track.reset()
})
}
}
彈幕池
無(wú)法復(fù)制加載中的內(nèi)容 通過(guò)HTML+CSS實(shí)現(xiàn)的彈幕,每一個(gè)彈幕會(huì)對(duì)應(yīng)一個(gè) DOM 元素,為了減少頻繁的創(chuàng)建,會(huì)在屏幕的左側(cè)把上一輪已經(jīng)滾出舞臺(tái)的彈幕存到池子中,當(dāng)有新彈幕時(shí)會(huì)重新復(fù)用。
軌道

從我們平常見(jiàn)到的彈幕中可以看到,其實(shí)舞臺(tái)中間會(huì)存在多條平行的軌道,舞臺(tái)和軌道之間的關(guān)系是1對(duì)多的關(guān)系。當(dāng)彈幕運(yùn)行時(shí),依次渲染軌道中的彈幕。所以,軌道中會(huì)存在一個(gè)彈幕數(shù)組,代表著目前正在軌道上展示的彈幕;以及一個(gè)叫offset的變量,代表著目前軌道已被占據(jù)的寬度。
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)
}
// 移除第一個(gè)(也就是剛剛出去的一個(gè))
removeTop() {
this.barrages.shift()
}
remove(index: number) {
if (index < 0 || index >= this.barrages.length) {
return
}
this.barrages.splice(index, 1)
}
// 更新 Offset,只需要關(guān)注軌道中最后一個(gè)彈幕
updateOffset() {
const endBarrage = this.barrages[this.barrages.length - 1]
if (endBarrage) {
const { speed } = endBarrage
this.offset -= speed
}
}
}

碰撞
彈幕的碰撞控制以及彈幕的呈現(xiàn)方式,其實(shí)全憑產(chǎn)品需求和個(gè)人喜好決定。以大多數(shù)彈幕為例,除了 B站的實(shí)現(xiàn)比較多樣化之外,更多的實(shí)現(xiàn)是通過(guò)平行軌道的方式實(shí)現(xiàn)。如果需要考慮彈幕的碰撞問(wèn)題,一般有兩種方法:
每個(gè)彈幕的速度都是相同的,所以也就不存在碰撞問(wèn)題,但是效果非常死板。 每個(gè)彈幕的速度都是不一樣的,但是需要解決碰撞問(wèn)題。
為了實(shí)現(xiàn)不同的速度,最簡(jiǎn)單有效的方式其實(shí)就是通過(guò)『追及問(wèn)題』求出彈幕的最大速度。
通過(guò)『追及問(wèn)題』,很容易求出彈幕B的最大速度 VB 。但是 VB 不應(yīng)該是彈幕的最終速度,考慮到距離 S 可能會(huì)比較大,那么 VB 的速度就會(huì)很大。于此同時(shí),應(yīng)該給彈幕的速度增加一點(diǎn)隨機(jī)性。因此,彈幕的速度比較好的呈現(xiàn)方式是:
S = Math.max(VB, Random * DefaultSpeed)
DefaultSpeed 第一個(gè)彈幕在軌道上的默認(rèn)速度,它應(yīng)該根據(jù)實(shí)際需求設(shè)置成一個(gè)合適的值,然后 VB 的最大值不能超過(guò)它,不然的話彈幕只能在軌道上『一閃而過(guò)』。
Demo
https://logcas.github.io/a-barrage/example/css3.html
https://logcas.github.io/a-barrage/example/canvas.html
參考資料
https://w3c.github.io/danmaku/usecase.zh.html
https://juejin.cn/post/6867689680670818317
?? 謝謝支持
以上便是本次分享的全部?jī)?nèi)容,希望對(duì)你有所幫助^_^
喜歡的話別忘了 分享、點(diǎn)贊、收藏 三連哦~。
歡迎關(guān)注公眾號(hào) 前端Sharing 收貨大廠一手好文章~
