浅谈前端弹幕的设计

程序员成长指北

共 12059字,需浏览 25分钟

 ·

2021-08-25 01:01

 大厂技术  高级进阶  精选好文

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群

背景

为了创造更好的多媒体体验,许多视频网站都添加了社交机制,使用户可以在媒体时间轴上的特定点发布评论和查看其他人的评论,其中一种机制被称为弹幕(dàn mù),在日语中也称为コメント(comment)或者弾幕(danmaku),在播放过程中,可能会出现大量评论和注释,并且直接渲染在视频上。弹幕最初是由日本视频网站Niconico(ニコニコ)引入的。在中国,除了在Bilibili和AcFun等弹幕视频网站中使用之外,其他主流视频网站(例如腾讯视频,爱奇艺视频,优酷视频和咪咕视频)中的视频播放器也支持弹幕。


形式

单条弹幕的基本模式有三种:

  1. 滚动弹幕:自右向左滚动过屏幕的弹幕,以自上而下的优先度展示。
  2. 顶部弹幕:自上而下静止居中的弹幕、以自上而下的优先度展示。
  3. 底部弹幕:自下而上静止居中的弹幕、以自下而上的优先度展示。

为什么需要弹幕

从用户体验角度出发——没有弹幕之前

在没有弹幕之前,我们一般是通过评论或者聊天室的方式去进行互动:(如上,左边视频,右边互动区)

传统互动方式带来的问题是,当我们的人眼的关注点在视频上时,是没办法进行“一眼二用”的,简单的来说就是,你没办法让你的两颗眼珠子往不同的方向看。这样带来的弊端是,当用户专注于视频时,互动区的交互效果是很差的;而当用户在看互动区的评论时,又没办法去关注整件事的主体内容,顾此失彼。

(你没办法“一眼二用”) 与此同时,对于世界上大多数的人来说,自小养成的习惯就是从左往右的阅读习惯。像这种互动区的评论,通常都是从下往上进行自动滚动的,两个方向的合起来的话整个文字就形成了一个倾斜的运动方向,使得用户的阅读产生了障碍。(倾斜向上的文字移动,让人没办法好好看字)

从用户体验角度出发——弹幕出现之后

弹幕出现后,我们的视角就集中到视频主体上,当弹幕出现时,如果是滚动弹幕,那么一般都是从右往左出发,非常适合我们的从左往右的阅读习惯,并且,文字的移动方向只有一个,不会给我们的阅读产生障碍。

除此之外的好处

互动性强:点播时让你觉得不孤独

在观看视频网站提供视频时,观看者在观看视频内容过程中根据内容启发会有一些想法或者吐槽点,就想要发表出来和更多的人分享,这时就需要弹幕来满足这个需求。通过弹幕,可以把同一时间观看者的评论通过固定方向滚动的方式显示在视频区域中,或者静止的显示在视频区域的顶部或底部,这样可以增加观看者和视频的互动特性以及观看者之间的互动。在相同时刻发送的弹幕基本上也具有相同的主题。

互动性强:直播时的互动及时

弹幕在视频直播场景中也能够成为主播与观众直接互动的方式。比起传统的实时评论,主播能够根据屏幕上弹幕的展现更直观了解观众的需求和反馈,更方便地调整接下来的行动和处理,也能够根据用户的输入进行交互操作。

气氛渲染好:“前方高能”

当看一些比较恐怖、悬疑的内容时,“前方高能”可能会避免你心里落下童年阴影[手动狗头]。

弹幕的实现方式

现如今,从B站、爱奇艺、腾讯视频等各大媒体网站上按下 F12 时,很容易发现是通过 HTML+CSS 的方式实现的。另外,也有一小部分具备 Canvas 实现的弹幕,比如之前的B站(不过在截稿前好像找不到切换按钮了)。

假如通过 HTML+CSS 实现

通过 DOM 元素实现弹幕,前端同学可以很方便地通过 CSS 修改弹幕样式。同时,得益于浏览器原生的 DOM 事件机制,借助这个可以很快捷实现一系列弹幕交互功能:个性化、点赞、举报等,以满足产品的各种互动需求。很容易看到,目前像腾讯视频、爱奇艺等都是通过 DOM 元素实现弹幕,这是目前主流的实现方式。

假如通过 Canvas 实现

Canvas 为动画而生,但是基于 Canvas 实现一个弹幕系统,会比基于 DOM 实现要复杂。暂且不说对于大部分前端同学而言,对 Canvas 的熟悉程度远比 DOM 要低,更何况,Canvas 并没有一套原生的事件系统,这意味着,如果要实现一些互动功能,你必须要自己实现一套 Canvas 的事件机制……

弹幕的设计

首先是整体设计,主要是三个部分:舞台、轨道、弹幕池。

舞台

舞台是整个弹幕的主控制,它维护着多个轨道、一个等待队列、一个弹幕池。舞台要做的事情是控制整个弹幕的节奏,当每一帧进行渲染时,都判断其中的轨道是否有空位,从等待队列中取合适的弹幕送往合适的轨道。舞台的能力可以通过实现舞台基类以及对应的抽象函数,让具体类型的舞台去实现对应的舞台逻辑。从而实现不同渲染能力(Canvas、HTML+CSS)以及不同类型(滚动、顶部固定、底部固定)的弹幕控制。无法复制加载中的内容 不管是通过 Canvas 还是 DOM 实现弹幕,需要的方法都是相似的:添加新弹幕到等待队列、寻找合适的轨道、从等待队列中抽取弹幕并放入轨道、整体渲染、清空。因此 BaseStage 可以通过编排抽象方法,让具体的子类去进行具体实现。

export default abstract class BaseStage<T extends BarrageObjectextends 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 BarrageObjectextends 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 BarrageObjectextends BaseStage<T
  el: HTMLDivElement 
  objToElmWeakMap<T, HTMLElement> = new WeakMap() 
  elmToObjWeakMap<HTMLElement, T> = new WeakMap() 
  freezeBarrage: T | null = null 
  domPoolArray<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 元素,为了减少频繁的创建,会在屏幕的左侧把上一轮已经滚出舞台的弹幕存到池子中,当有新弹幕时会重新复用。

轨道


从我们平常见到的弹幕中可以看到,其实舞台中间会存在多条平行的轨道,舞台和轨道之间的关系是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 
    } 
  } 

碰撞

弹幕的碰撞控制以及弹幕的呈现方式,其实全凭产品需求和个人喜好决定。以大多数弹幕为例,除了 B站的实现比较多样化之外,更多的实现是通过平行轨道的方式实现。如果需要考虑弹幕的碰撞问题,一般有两种方法:

  1. 每个弹幕的速度都是相同的,所以也就不存在碰撞问题,但是效果非常死板。
  2. 每个弹幕的速度都是不一样的,但是需要解决碰撞问题。

为了实现不同的速度,最简单有效的方式其实就是通过『追及问题』求出弹幕的最大速度。通过『追及问题』,很容易求出弹幕B的最大速度 VB 。但是 VB 不应该是弹幕的最终速度,考虑到距离 S 可能会比较大,那么 VB 的速度就会很大。于此同时,应该给弹幕的速度增加一点随机性。因此,弹幕的速度比较好的呈现方式是:

S = Math.max(VB, Random * DefaultSpeed) 


DefaultSpeed 第一个弹幕在轨道上的默认速度,它应该根据实际需求设置成一个合适的值,然后 VB 的最大值不能超过它,不然的话弹幕只能在轨道上『一闪而过』。

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


Node 社群


我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。


   “分享、点赞在看” 支持一波 



浏览 45
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报