淺談彈幕的設計

背景

爲了創造更好的多媒體體驗,許多視頻網站都添加了社交機制,使用戶能夠在媒體時間軸上的特定點發布評論和查看其餘人的評論,其中一種機制被稱爲彈幕(dàn mù),在日語中也稱爲コメント(comment)或者弾幕(danmaku),在播放過程當中,可能會出現大量評論和註釋,而且直接渲染在視頻上。 彈幕最初是由日本視頻網站Niconico(ニコニコ)引入的。在中國,除了在Bilibili和AcFun等彈幕視頻網站中使用以外,其餘主流視頻網站(例如騰訊視頻,愛奇藝視頻,優酷視頻和咪咕視頻)中的視頻播放器也支持彈幕。 image.pngjavascript

形式

單條彈幕的基本模式有三種:css

  1. 滾動彈幕:自右向左滾動過屏幕的彈幕,以自上而下的優先度展現。
  2. 頂部彈幕:自上而下靜止居中的彈幕、以自上而下的優先度展現。
  3. 底部彈幕:自下而上靜止居中的彈幕、以自下而上的優先度展現。

爲何須要彈幕

從用戶體驗角度出發——沒有彈幕以前

在沒有彈幕以前,咱們通常是經過評論或者聊天室的方式去進行互動: image.png (如上,左邊視頻,右邊互動區)html

傳統互動方式帶來的問題是,當咱們的人眼的關注點在視頻上時,是沒辦法進行「一眼二用」的,簡單的來講就是,你沒辦法讓你的兩顆眼珠子往不一樣的方向看。這樣帶來的弊端是,當用戶專一於視頻時,互動區的交互效果是不好的;而當用戶在看互動區的評論時,又沒辦法去關注整件事的主體內容,顧此失彼。前端

image.png (你沒辦法「一眼二用」) 與此同時,對於世界上大多數的人來講,自小養成的習慣就是從左往右的閱讀習慣。像這種互動區的評論,一般都是從下往上進行自動滾動的,兩個方向的合起來的話整個文字就造成了一個傾斜的運動方向,使得用戶的閱讀產生了障礙。 image.png (傾斜向上的文字移動,讓人沒辦法好好看字)java

從用戶體驗角度出發——彈幕出現以後

image.png 彈幕出現後,咱們的視角就集中到視頻主體上,當彈幕出現時,若是是滾動彈幕,那麼通常都是從右往左出發,很是適合咱們的從左往右的閱讀習慣,而且,文字的移動方向只有一個,不會給咱們的閱讀產生障礙。 image.pngcss3

除此以外的好處

互動性強:點播時讓你以爲不孤獨

image.png 在觀看視頻網站提供視頻時,觀看者在觀看視頻內容過程當中根據內容啓發會有一些想法或者吐槽點,就想要發表出來和更多的人分享,這時就須要彈幕來知足這個需求。經過彈幕,能夠把同一時間觀看者的評論經過固定方向滾動的方式顯示在視頻區域中,或者靜止的顯示在視頻區域的頂部或底部,這樣能夠增長觀看者和視頻的互動特性以及觀看者之間的互動。在相同時刻發送的彈幕基本上也具備相同的主題。git

互動性強:直播時的互動及時

image.png 彈幕在視頻直播場景中也可以成爲主播與觀衆直接互動的方式。比起傳統的實時評論,主播可以根據屏幕上彈幕的展示更直觀瞭解觀衆的需求和反饋,更方便地調整接下來的行動和處理,也可以根據用戶的輸入進行交互操做。github

氣氛渲染好:「前方高能」

image.png 當看一些比較恐怖、懸疑的內容時,「前方高能」可能會避免你內心落下童年陰影[手動狗頭]。canvas

彈幕的實現方式

現現在,從B站、愛奇藝、騰訊視頻等各大媒體網站上按下 F12 時,很容易發現是經過 HTML+CSS 的方式實現的。另外,也有一小部分具有 Canvas 實現的彈幕,好比以前的B站(不過在截稿前好像找不到切換按鈕了)。數組

假如經過 HTML+CSS 實現

經過 DOM 元素實現彈幕,前端同窗能夠很方便地經過 CSS 修改彈幕樣式。同時,得益於瀏覽器原生的 DOM 事件機制,藉助這個能夠很快捷實現一系列彈幕交互功能:個性化、點贊、舉報等,以知足產品的各類互動需求。很容易看到,目前像騰訊視頻、愛奇藝等都是經過 DOM 元素實現彈幕,這是目前主流的實現方式。

假如經過 Canvas 實現

Canvas 爲動畫而生,可是基於 Canvas 實現一個彈幕系統,會比基於 DOM 實現要複雜。暫且不說對於大部分前端同窗而言,對 Canvas 的熟悉程度遠比 DOM 要低,更況且,Canvas 並無一套原生的事件系統,這意味着,若是要實現一些互動功能,你必需要本身實現一套 Canvas 的事件機制……

彈幕的設計

首先是總體設計,主要是三個部分:舞臺、軌道、彈幕池。

舞臺

舞臺是整個彈幕的主控制,它維護着多個軌道、一個等待隊列、一個彈幕池。舞臺要作的事情是控制整個彈幕的節奏,當每一幀進行渲染時,都判斷其中的軌道是否有空位,從等待隊列中取合適的彈幕送往合適的軌道。 image.png 舞臺的能力能夠經過實現舞臺基類以及對應的抽象函數,讓具體類型的舞臺去實現對應的舞臺邏輯。從而實現不一樣渲染能力(Canvas、HTML+CSS)以及不一樣類型(滾動、頂部固定、底部固定)的彈幕控制。 沒法複製加載中的內容 不論是經過 Canvas 仍是 DOM 實現彈幕,須要的方法都是類似的:添加新彈幕到等待隊列、尋找合適的軌道、從等待隊列中抽取彈幕並放入軌道、總體渲染、清空。所以 BaseStage 能夠經過編排抽象方法,讓具體的子類去進行具體實現。

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[] = [] 
 
  // 添加彈幕到等待隊列 
  abstract add(barrage: T): boolean 
  // 尋找合適的軌道 
  abstract _findTrack(): number 
  // 從等待隊列中抽取彈幕並放入軌道 
  abstract _extractBarrage(): void 
  // 渲染函數 
  abstract render(): void 
  // 清空 
  abstract reset(): void 
} 
複製代碼

Canvas 版本

好比,Canvas的舞臺基類須要傳入Canvas元素,獲取Context。最後經過實現 BaseStage 的抽象方法實現具體的邏輯。

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 版本

而對於HTML+CSS的實現,就須要維護一個彈幕池domPool、彈幕實例與DOM的映射關係(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() 
    }) 
  } 
} 
複製代碼

彈幕池

沒法複製加載中的內容 經過HTML+CSS實現的彈幕,每個彈幕會對應一個 DOM 元素,爲了減小頻繁的建立,會在屏幕的左側把上一輪已經滾出舞臺的彈幕存到池子中,當有新彈幕時會從新複用。

軌道

image.png 從咱們日常見到的彈幕中能夠看到,其實舞臺中間會存在多條平行的軌道,舞臺和軌道之間的關係是1對多的關係。當彈幕運行時,依次渲染軌道中的彈幕。 因此,軌道中會存在一個彈幕數組,表明着目前正在軌道上展現的彈幕;以及一個叫offset的變量,表明着目前軌道已被佔據的寬度。

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) 
  } 
 
  // 更新 Offset,只須要關注軌道中最後一個彈幕 
  updateOffset() { 
    const endBarrage = this.barrages[this.barrages.length - 1] 
    if (endBarrage) { 
      const { speed } = endBarrage 
      this.offset -= speed 
    } 
  } 
} 
複製代碼

image.png

碰撞

彈幕的碰撞控制以及彈幕的呈現方式,其實全憑產品需求和我的喜愛決定。以大多數彈幕爲例,除了 B站的實現比較多樣化以外,更多地實現是經過平行軌道的方式實現。若是須要考慮彈幕的碰撞問題,通常有兩種方法:

  1. 每一個彈幕的速度都是相同的,因此也就不存在碰撞問題,可是效果很是死板。
  2. 每一個彈幕的速度都是不同的,可是須要解決碰撞問題。

爲了實現不一樣的速度,最簡單有效的方式其實就是經過『追及問題』求出彈幕的最大速度。 image.png 經過『追及問題』,很容易求出彈幕B的最大速度 VB 。可是 VB 不該該是彈幕的最終速度,考慮到距離 S 可能會比較大,那麼 VB 的速度就會很大。於此同時,應該給彈幕的速度增長一點隨機性。所以,彈幕的速度比較好的呈現方式是:

S = Math.max(VB, Random * DefaultSpeed) 
複製代碼

DefaultSpeed 第一個彈幕在軌道上的默認速度,它應該根據實際需求設置成一個合適的值,而後 VB 的最大值不能超過它,否則的話彈幕只能在軌道上『一閃而過』。

Demo

logcas.github.io/a-barrage/e… logcas.github.io/a-barrage/e…

參考資料

w3c.github.io/danmaku/use…

juejin.cn/post/686768…

相關文章
相關標籤/搜索