vortesnail / blog

:blue_book: 个人技术小文章,旨在对知识的总结,能帮助到别人就更好啦。
553 stars 45 forks source link

弹幕的常规设计与实现 #20

Open vortesnail opened 2 years ago

vortesnail commented 2 years ago

引言

在 2022 年的今天,弹幕在国内的各大视频网站已经成为了一个最基本的评论交互形式,它为视频社交增添了很大的活力,人是渴望交流且拥有共情能力的物种,对于同一个视频某一个时间节点,不同的人可以在弹幕中看到与自己有相同看法或有趣的评论,这无形中增加了视频观看者的共同参与感。

回想一下没有弹幕的过去,我们对某个情节有讨论或看法时,只能暂停视频,来到评论区,敲下几个字再回去播放视频。这种视频与用户的割裂感问题在弹幕出现后得到了很好的解决。

本文会介绍下弹幕是什么以及相比传统的聊天室(滚动评论)模式有哪些优点,之后会以一个较为简单的思路给大家说说如何在 web 端开发我们自己的弹幕系统,包括滚动弹幕顶部固定弹幕底部固定弹幕

弹幕简介

弹幕的读音为 dàn mù,因为大量评论在视频上方滚动时很像飞行射击游戏里的“弹幕”,所以国人命名如此,而在日语中被称为 danmaku ,注意了,不是 danmuku

弹幕的发明者具体到个人不是很清楚,这种评论形式最初是在日本的线上影片分享网站 Niconico 动画 出现,后来被 AcFun 引进,再后来大家都知道了,Bilibili 出现后将这种形式发扬光大,可以说弹幕成就了B站,B站也发扬了弹幕这种形式。

现如今国内各大视频平台,发弹幕已经成为了最基本的功能了,不过就我目前体验来看,还是B站的弹幕花样最多,但实际上对于腾讯视频、爱奇艺这种偏影视的视频网站来说,也不需要多少花样,不然适得其反。

弹幕得以发展的原因

在弹幕这种评论形式出现之前,对于在线视频的用户,他们之间的实时交流方式主要是聊天室模式,用户输入文本内容后,文本列表将整体从下向上滚动,如下图:

1

而在弹幕出现之后,用户输入文本内容后,文本将出现在视频右侧,在独立的轨道中从右向左移动,如下图:

2

两者之间各有各的优势,不过就现在用户的观看习惯来说,弹幕带来的用户体验是比聊天室模式要好的,接下来介绍下弹幕都有哪些优势吧~

评论同屏密度

与聊天室模式相比,弹幕模式有更宽的展示区域,毕竟用户观看的信息主体在视频内容,没有哪个网站会把聊天框的宽度设置的比视频宽度还大的。同一条评论,因为弹幕是横向轨道内移动,所以不会像聊天室模式下由于句子过长而导致这行而占据更多高度。所以同屏下,对于人眼来说,弹幕模式比聊天室模式接收到的信息更多。

3

评论更新频率

在聊天室模式中,所有评论都是以相同的频率向上滚动,一条评论的出现会将所有评论向上顶,而弹幕模式下每条评论都在独立的轨道中移动,并不受其他评论的出现所影响,可以通过算法保障每条评论在屏幕内的展示时长。

4

视线移动适应性

在聊天室模式中,用户如果关注视频内容则无法阅读评论。而弹幕模式通过把文字覆盖于视频画面之上让用户可以同时阅读评论与观看视频,无需视线在两个区域间往返移动,有更好的沉浸体验。

以下是聊天室模式下,我们的视线移动方向示意图:

5

以下是弹幕模式下,我们的视线聚焦范围示意图:

6

阅读习惯

我们大多数人(除了像阿拉伯这种国家的人民)阅读习惯是从左到右从上到下,因此人们养成了横向阅读单行信息的习惯。

在弹幕模式下文字从右向左移动,人从左向右阅读,形成从左向右的合力,在这种模式下我们可以用较短时间就能理解文字的含义。

以下是弹幕模式下,视觉的合力方向示意图:

7

而在聊天室模式下人从左到右阅读,而阅读中的文字则在不停的向上移动,形成一个倾斜向上的合力,这会对快速阅读产生障碍。

以下是聊天室模式下,视觉的合力方向示意图:

8

心理因素

弹幕的出现能使多个用户对同一视频时间点于不同的时空发表看法,有一种跨越时空交流的感觉,极大的增强了用户的参与感。

在观看视频的某一时间节点,在弹幕上能看到很多与自己相同的观点,会觉得这个世界上有很多和你一样想法的人,会有一种共鸣感。另外,有时候还会看到很有意思的弹幕,惹的人会心一笑,还会看到一些很有哲理、对自己的知识扩展也很有帮助的弹幕。

弹幕的实现方式

在现在市面上,弹幕的实现可以分为两种方式,一个是 HTML+CSS 方式,另一个是 Canvas 方式。

前者实现能很方便地给每条弹幕添加事件,比如我们常用的移到弹幕上悬停并弹框跳出选项,这得益于原生的 DOM 事件很容易做到。而后者实现需要自己去写一套事件机制,对于像我这种对 Canvas 不太熟的前端,就比较麻烦了,不过愿意花个几天时间去搞一搞倒也不是什么问题。

9

两者在性能上还是有区别的,结论是 HTML+CSS 的性能是没有 Canvas 实现好的,前者会在页面下创建非常多的 DOM 节点,当同屏弹幕过多导致出现大量 DOM 节点时,对于一些“老机器”说不准都能卡死。所以大家会看到,在直播时,对性能要求很高,比如很多视频网站直播就会采用 Canvas 的实现方式去创建弹幕。

接下来我们的代码实操采用 HTML+CSS 方式实现,对 Canvas 感兴趣的,按照相同的设计思路也是能写出来的。

弹幕的设计

我们开始对弹幕的实现做一个功能上的设计,在后面代码实操之前让大家有一个初步的设计思路,更容易理解代码的意思。

首先我们看下一个视频中常规的弹幕画面:

10

其中我使用“红线”标记出来的就是每轮弹幕所滚动的区域,我们称这个区域为轨道,图中又画了我们常见的滚动弹幕底部弹幕,当然,为了图简洁点,顶部弹幕我就不画了。

目前我们能确定的信息是,弹幕系统需要一个轨道的承载逻辑,同时背后还需要一个指挥官来决定每一条新加入的弹幕是去往哪个轨道,什么时候开始渲染弹幕,以及动画逻辑。

轨道

从弹幕的呈现效果可以很明显得知,一个轨道内有若干的弹幕,而且弹幕的出现是依次进行的,这意味着需要一个容器来把这些弹幕装进去,适合的时机再一条条放出来。为了让指挥官能计算出这个时机,我们还需要一个变量 offset 来表示轨道已占据的宽度

根据上面的简单分析,我们可以先定义好表示这个轨道的类和它所需要的属性:

class Track<T extends Danmu> {
  danmus: T[] = [];         // 弹幕数组
  offset = 0;               // 轨道已占据的宽度
}

容器我们有了 danmus 这个数组,但是如何添加、删除弹幕呢?那就要定义方法了:

class Track<T extends Danmu> {
  danmus: T[] = [];         // 弹幕数组
  offset = 0;               // 轨道已占据的宽度

  push(...items: T[]) {};   // 添加弹幕

  remove(index: number) {}; // 删除弹幕

  reset() {};               // 重置弹幕数组及轨道已占据的宽度
}

最后还有一个特别重要的方法是用于更新轨道已占据的宽度,在后面会说到渲染弹幕动画的时候,弹幕进入轨道的时机是由这个所占据轨道的宽度来计算的,所以弹幕的每次移动,我们都需要去对轨道的所占据宽度进行更新。

class Track<T extends Danmu> {
  // ...
  updateOffset() {};        // 更新弹幕已占据的宽度
}

这就是整个轨道的设计,非常简洁明了,它的职责非常清晰:管理轨道内弹幕的增加、删除及已占据宽度的更新,但是不负责渲染不负责渲染不负责渲染

⚠️ 看到这里如果有心情仔细了解逻辑代码是怎么样的,可以点击这个仓库查看源码:qier-player-danmaku

指挥官

由前文可知,我们一个视频画布内是有多条轨道的,那么如何管理这些轨道呢?那就是背后的指挥官在“负重前行”了。

首先是一种指挥官管理了当前弹幕类型下的 2N 个轨道:

11

而我们这次的弹幕设计中包括了滚动弹幕顶部固定弹幕底部固定弹幕,所以对于指挥官我们又有了以下层级关系:

12

然而这些指挥官是有共同的属性及方法的,我们可以抽象成一个指挥官基类(BaseCommander),简单的代码结构如下:

export default abstract class BaseCommander<T extends Danmu> {
  protected tracks: Track<T>[] = [];  // 轨道数组
  waitingQueue: T[] = [];             // 等待队列

  constructor(config: Commander) {
    super();        
    for (let i = 0; i < config.trackCnt; ++i) {
      this.tracks[i] = new Track();
    }
  }

  abstract add(danmu: T): boolean;    // 创建弹幕实例并添加弹幕到等待队列
  abstract findTrack(): number;       // 寻找合适的轨道
  abstract extractDanmu(): void;      // 从等待队列中抽取弹幕并放入弹幕
  abstract render(): void;            // 渲染函数
  abstract reset(): void;             // 重置清空
}

有了指挥官基类,我们就可以去分别实现各种类型的指挥官了,在这里我不想把指挥官的实现代码贴出来,放一张思路图,如果有兴趣的,可以再去阅读源码:qier-player-danmaku

13

render

上面的图除了“用户发送弹幕”这一步,其它所有步骤是我们的核心实现 render ,所以我们来对此方法做一个剖析。它的工作职责为下图:

14

从等待队列中抽取合适的弹幕放入轨道

每一个指挥官(Commander)都有自己的等待队列 waitingQueue,里面存放的是所有还未被渲染到画布上的弹幕,每次在 render 方法执行时,要把等待队列中的弹幕添加到合适的轨道上去,这个过程由 extractDanmu 方法实现:

extractDanmu(): void {
  let isAdded: boolean;
  for (let i = 0; i < this.waitingQueue.length; ) {
    isAdded = this.add(this.waitingQueue[i]);
    // 若有一次无法添加成功,说明无轨道可用,终止剩余弹幕的 add 尝试
    if (!isAdded) {
      break;
    }
    this.waitingQueue.shift();
  }
}

这里的思想是,遍历等待队列中的弹幕,尝试将其通过 add 方法添加到轨道上,如果添加成功,将该弹幕从等待队列中删掉,进行后面的弹幕的添加。

但是遍历过程中若出现了一次添加失败,证明所有轨道都没有办法再添加新的弹幕了,我们就要停止遍历。

add 方法的作用即为将弹幕添加到合适的轨道上,而实现这一目的还需要以下过程:

  1. 找到合适的轨道;

  2. 创建真实的弹幕 DOM 并计算速度;

  3. 将其推入要被加入的轨道的 danmus 数组中,计算弹幕的 offset

另外,add 是继承指挥官基类的抽象方法的具体实现,这里我们以最复杂的滚动弹幕作为实现举例:

add(danmu: RollingDanmu): boolean {
  const trackId = this.findTrack();
  if (trackId === -1) {
    return false;
  }

  const { text, color, size, offset } = danmu;
  const danmuDom = createDanmu(text, color, size, this.trackHeight, offset);
  this.el.appendChild(danmuDom);
  const width = danmuDom.offsetWidth;

  // 根据追及问题,计算弹幕的速度
  const track = this.tracks[trackId];
  const trackOffset = track.offset;
  const trackWidth = this.trackWidth;
  let speed: number;
  if (isEmptyArray(track.danmus)) {
    speed = this.defaultSpeed * this.randomSpeed;
  } else {
    const { speed: preSpeed } = getArrayLast<RollingDanmu>(track.danmus);
    speed = (trackWidth * preSpeed) / trackOffset;
  }
  // 防止速度过快一闪而过,最大值只能为平均速度的 2 倍
  speed = Math.min(speed, this.defaultSpeed * 2);
  const normalizedDanmu = { ...danmu, offset: trackWidth, speed, width };
  track.push(normalizedDanmu);
  track.offset = trackWidth + normalizedDanmu.width * 1.2;

  return true;
}

上面代码中第一行就是 findTrack 方法,目的就是找到合适的轨道,如何找到合适的轨道呢?

findTrack(): number {
  const failCode = -1;
  let idx = failCode;
  let max = -Infinity;
  this.each((track, index) => {
    const trackOffset = track.offset;
    if (trackOffset > this.trackWidth) {
      return failCode;
    }
    const t = this.trackWidth - trackOffset;
    // 策略为找到剩余空间最大的轨道进行插入
    if (t > max) {
      idx = index;
      max = t;
    }
  });
  return idx;
}

这里采取的实现策略还是比较直观简单的,从上往下遍历轨道,找到第一个能插入的轨道,

15

找到合适的轨道就返回其下标,继续执行后面逻辑,没有就返回 false

接下来我们探索下怎么计算弹幕的速度,这里弹幕的速度看产品喜好,下面介绍两种:

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

  2. 每个弹幕的速度都是不一样的,但是需要解决碰撞问题。。

为了实现不同的速度,最简单有效的方式其实就是通过追及问题求出弹幕的最大速度。

16

假设现在轨道长度为 L ,轨道上已存在的最后一个弹幕A已经飞过了距离 S ,其速度已知是 vA ,那么如何计算弹幕B的速度呢?

首先我们假设弹幕B弹幕A要在同一时间达到轨道的终点,就会得到以下的等式:

17

于是见很简单的推理出了弹幕B的速度为 vB ,转换为我们代码里面的变量名就是后面红色字的等式。

但是这样会有一个问题就是,假入弹幕A已经快到了轨道终点了,这样就会造成计算出的弹幕B的速度过大,具体表现即为弹幕飞的很快一闪而过,这种体验是很差的,所以我们需要有一个最大速度的限制,在上面 add 方法中有这么一行代码就是这个作用:

speed = Math.min(speed, this.defaultSpeed * 2);

这里 defaultSpped 大小为平均速度,所以这里的限制即为平均速度的 2 倍。

后面就是将创建好的弹幕 DOM 放到指定的轨道中的 danmus 数组等待渲染即可。

遍历轨道数组,依次渲染轨道中的弹幕

放出 render 方法的实现,我们主要经历以下四个过程:

  1. 遍历每个轨道中的每个弹幕;

  2. 获取弹幕 DOM 并计算 translateX

  3. 更新轨道的偏移量 offset

  4. 移除超出画布的弹幕。

render(): void {
  this.extractDanmu();
  const objToElm = this.objToElm;
  const trackHeight = this.trackHeight;
  this.each((track, trackIdx) => {
    let shouldRemove = false;
    let removeIndex = -1;
    track.each((danmu, danmuIdx) => {
      if (!objToElm.has(danmu)) {
        return;
      }
      if (danmu.static) {
        return;
      }
      const danmuDom = objToElm.get(danmu)!;
      const offset = danmu.offset;
      danmuDom.style.transform = `translate(${offset}px, ${trackIdx * trackHeight}px)`;
      // 每一帧后弹幕的偏移量都会减少 speed 大小的距离
      danmu.offset -= danmu.speed;
      if (danmu.offset < 0 && Math.abs(danmu.offset) > danmu.width) {
        shouldRemove = true;
        removeIndex = danmuIdx;
      }
    });
    track.updateOffset();
    if (shouldRemove) {
      this.removeElementFromTrack(track, removeIndex);
      track.remove(removeIndex);
    }
  });
}

以上代码应该是很容易看出对应的过程,这里就不细述了。

总结

这篇文章简单的讨论了弹幕的常规设计与实现思路,里面还有可以优化的点,比如弹幕的速度问题,还有没有提到的事件监听,具体实现大家如果有兴趣可以阅读下源码:

qier-player-danmaku

另外,如果对大家有所帮助,给我的 vortesnail/blog 赏个 star🌟 哦~

参考:

弹幕用例规范

a-barrage

ZOUYIQAQ commented 1 year ago

感谢大佬,受益匪浅