浅谈弹幕的设计

背景

为了创造更好的多媒体体验,许多视频网站都添加了社交机制,使用户能够在媒体时间轴上的特定点发布评论和查看其余人的评论,其中一种机制被称为弹幕(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…

相关文章
相关标签/搜索