作者 | 翁鹏

42

弾幕(だんまく/danmaku)、barrage 是显示在影片上的评论,大量吐槽评论从屏幕飘过时效果看上去像是飞行射击游戏里的弹幕。弹幕视频系统源自日本弹幕视频分享网站(niconico动画),国内首先引进为 AcFun 以及后来的 bilibili。

这篇文章将介绍 3 种实现方法,并找出可以兼容多个浏览器并且流畅播放的方案。

思路

视频弹幕可以分为两种,一种是静止显示在视频的顶部或底部,另一种是从右到左滚动显示。静止弹幕比较简单,这里主要介绍滚动弹幕。

视频播放时会有很大量弹幕从右边移动到左边,为了方便观看会限制单次导入的弹幕数量,并且让弹幕之间不发生重叠。如果用户开启无限弹幕模式,那么就无需限制弹幕数量和弹幕之间是否重叠。

可以将视频分割成一行行的隧道,每次插入弹幕时选择当前最短的一个隧道插入。

速度

因为每个弹幕的显示时间固定,所以长弹幕的速度比短弹幕的速度快。但是长弹幕的速度也不能太快,这样会让它覆盖到前面比较短的弹幕。

为了让弹幕显示时间不要太长,那么就需要后一个弹幕到达最左端时,它前一个弹幕刚好消失。也就是后一个弹幕的速度比前一快,但是又不能太快,它的速度可以让它到达最左端时才追上前一个弹幕。

为了计算后一个弹幕的速度,我们可以设后一个弹幕的速度为 s,经过时间 t 后,后一个弹幕追上前一个弹幕。那么 t 就等于前一个弹幕要走的路程除以前一个弹幕的速度。知道了时间 t 那么 s 就等于后一

个弹幕的要走的路程除以时间 t

实现

方式 描述
requestAnimationFrame和transform 使用 js 控制弹幕的transform来实现滚动弹幕
requestAnimationFrame 和 canvas 与第一种相似,但是不使用的css,而是用 canvas 渲染
transform 和 transition 不自己控制动画,而是用 css3 transition 属性实现

这三种方法在chrome上都非常流畅。一般最先想到的方法就是第一种方法,它实现起来非常的简单,但是第一种方法在IE上有明显卡顿。

由于第一和第二种实现方式差不多,只是最终渲染的时候一个是操作 DOM,一个是操作canvas,所以这里就只演示比较流畅的canvas版本。

通用接口

这篇文章只关注弹幕的实现,一些通用代码就用接口表示了。

下面是一条弹幕

interface Item {text: string; // 评论time: number; // 显示的时间color?: string; // 颜色
}

播放器对象

interface Player {width: number; // 播放器宽度height: number; // 播放器高度currentTime: number; // 当前播放时间on: (event: string, callback: Function) => void; // 监听 video 元素事件appendChild: (dom: Element) => void; // 添加元素到播放器
}

canvas 版本

canvas 版本,实现比较直接。这里使用两个类 Danmaku 控制所有弹幕,Bullet 滚动的单个弹幕。

private update = (): void => {this.draw();this.timer = requestAnimationFrame(this.update);
};

我们使用 requestAnimationFrame 来循环执行 draw 方法。

draw 方法在没一帧中执行下面 4 个步骤:

  1. 加载当前时间点的弹幕

  2. 把加载的弹幕填入到合适的隧道中

  3. 更新弹幕位置

  4. 清理超出界限的弹幕

private draw(): void {const items = this.load(); // 加载弹幕,返回的是 Itemif (items) {for (let i = 0, l = items.length; i < l; i++) {if (!this.fill(items[i], i)) break; // 如果 fill 方法返回 false 代表,所有隧道已填满,放弃剩下的弹幕}}this.ctx.clearRect(0, 0, this.dom.width, this.dom.height); // 清理 canvasthis.bullets.forEach((bullet) => bullet.display()); // 移动弹幕位置this.clearBullets(); // 清理超出边界弹幕,放入弹幕池中,下次可以直接使用
}

其中比较复杂是 fill 方法。它会找出当前最短的隧道,然后判断是否可以插入弹幕。

private fill(item: Item, i = 0, force = false): boolean {const [tunnel, prevBullet] = this.getShortestTunnel(); // 获取最短隧道if (!prevBullet || prevBullet.length < this.width + 200) { // 这里限制弹幕数量的策略是,每个隧道长度不超过视频宽度加 200this.scroll[tunnel] = this.getBullet(item, tunnel, prevBullet);// scroll 记录每个隧道的最后一个弹幕this.bullets.push(this.scroll[tunnel]);item = undefined;}if (!item) return true;if (force) { // 如果是无限模式,就不关心是否会重叠弹幕this.scroll.push(this.getBullet(item, i % this.tunnel));return true;}return false;}
}

对于每个弹幕 Item,都会有个 Bullet 与它对应,下面是 Bullet 类完整代码。

class Bullet {private readonly ctx: CanvasRenderingContext2D;private readonly danmaku: Danmaku;item: Item;prevBullet: Bullet;width = 0;x = 0;y = 0;speed = 0;tunnel = 0;constructor(danmaku: Danmaku,item: Item,tunnel: number,prevBullet?: Bullet) {this.danmaku = danmaku;this.ctx = danmaku.ctx;this.reset(item, tunnel, prevBullet);}get length(): number { // 当前弹幕的长度return this.x + this.width;}get canRecycle(): boolean {return -this.x > this.width; // 是否可回收,当弹幕从屏幕上消失就可以回收它}reset(item: Item, tunnel: number, prevBullet?: Bullet): this {this.item = item;this.tunnel = tunnel;this.width = this.ctx.measureText(item.text).width;this.prevBullet = prevBullet;this.x = Math.max(prevBullet?.length ?? 0, this.danmaku.width);this.updateSpeed();this.updateY();return this;}recycle(): this {this.prevBullet = null;return this;}updateSpeed(): void {if (this.prevBullet && this.prevBullet.length > this.danmaku.width) {this.speed = this.prevBullet.speed;} else {this.speed = this.length / 500; // 这里为了简单换成具体的时间,弹幕只显示 500 帧if (this.prevBullet) {const maxSpeed = (this.x * this.prevBullet.speed) / this.prevBullet.length; // 上面提到的速度公式if (this.speed > maxSpeed) this.speed = maxSpeed;}}}updateY(): void {this.y = this.tunnel * this.danmaku.tunnelHeight;}display(): void {this.x -= this.actualSpeed;if (this.x > this.danmaku.width) return; // 只有出现在屏幕上才渲染this.ctx.fillStyle = this.item.color || '#fff';this.ctx.fillText(this.item.text, this.x, this.y);}
}

下面是 Danmaku 的完整代码。

class Danmaku {readonly player: RPlayer;private running = false;private timer: number;private remain: Item[] = []; // 剩余弹幕private prevCurrentTime = -1; // 上次加载弹幕时的视频时间private bullets: Bullet[] = []; // 当前正在显示的弹幕private scroll: Bullet[] = []; // 保存每个隧道最后一个弹幕private pool: Bullet[] = []; // 回收的弹幕池tunnel = 0; // 一共有多少隧道tunnelHeight = 0; // 隧道高度readonly ctx: CanvasRenderingContext2D;readonly dom: HTMLCanvasElement;constructor(player: Player, items: Item[]) {this.player = player;this.dom = document.createElement('canvas');this.ctx = this.dom.getContext('2d');this.remain = [...items];player.appendChild(this.dom);player.on('pause', this.stop);player.on('play', this.start);player.on('ended', this.stop);this.resizeTunnelHeight();this.resizeTunnel();this.start();}get font(): string {return `bold 24px/1.1 SimHei, "Microsoft JhengHei", Arial, Helvetica, sans-serif`;}get width(): number {return this.player.width;}get height(): number {return this.player.height;}private initCanvas(): void {this.ctx.font = this.font;}private resizeTunnel(): void {this.tunnel = (this.height / this.tunnelHeight) | 0;}private resizeTunnelHeight(): void { // 这里使用 DOM 的方式获取隧道高度const text = document.createElement('span');text.innerText = '中'text.style.font = this.font;text.style.position = 'absolute';text.style.opacity = '0';document.body.appendChild(text);const height = text.scrollHeight;document.body.removeChild(text);this.tunnelHeight = height + 1;}private start = (): void => {if (this.running) return;this.running = true;this.initCanvas();this.update();};private stop = (): void => {this.running = false;cancelAnimationFrame(this.timer);};private update = (): void => {this.draw();this.timer = requestAnimationFrame(this.update);};private draw(): void {const items = this.load();if (items) {for (let i = 0, l = items.length; i < l; i++) {if (!this.fill(items[i], i)) break;}}this.ctx.clearRect(0, 0, this.dom.width, this.dom.height);this.bullets.forEach((bullet) => bullet.display());this.clearBullets();}private recycleBullet(b: Bullet): void {if (this.pool.length < 20) { // 默认弹幕池大小小于 20this.pool.push(b.recycle());}}private clearBullets(): void { // 清理已经消失在屏幕上的弹幕const bullets: Bullet[] = [];this.bullets.forEach((b) => {if (b.canRecycle) {this.recycleBullet(b);} else {bullets.push(b);}});this.bullets = bullets;for (let i = 0; i < this.tunnel; i++) {if (this.scroll[i] && this.scroll[i].canRecycle) {this.scroll[i] = undefined;}}}private getBullet(item: Item, tunnel: number, prevBullet?: Bullet): Bullet {const bullet = this.pool.pop();if (bullet) return bullet.reset(item, tunnel, prevBullet);return new Bullet(this, item, tunnel, prevBullet);}private getShortestTunnel(): [number, Bullet] { // 获取最短的隧道let length = Infinity;let tunnel = -1;let bullet: Bullet = null;for (let i = 0; i < this.tunnel; i++) {if (!this.scroll[i] || this.scroll[i].canRecycle) return [i, null];const l = this.scroll[i].length;if (l < length) {length = l;tunnel = i;bullet = this.scroll[i];}}return [tunnel, bullet];}private load(): void | Item[] {if (!this.remain.length) return;const time = this.player.currentTime | 0;if (this.prevCurrentTime === time) return;this.prevCurrentTime = time;const remain: Item[] = [];let toShow: Item[] = [];for (let i = 0, l = this.remain.length; i < l; i++) {const item = this.remain[i];if (item.time === time) {toShow.push(item);} else if (item.time > time) {remain.push(item);}}this.remain = remain;if (!toShow.length) return;return toShow;}private fill(item: Item, i = 0, force = false): boolean {const [tunnel, prevBullet] = this.getShortestTunnel();if (!prevBullet || prevBullet.length < this.width + 200) { // 这里限制弹幕数量的策略是,每个隧道长度不超过视频宽度加 200this.scroll[tunnel] = this.getBullet(item, tunnel, prevBullet);this.bullets.push(this.scroll[tunnel]);item = undefined;}if (!item) return true;if (force) { // 如果是无限模式,就不关是否会重叠弹幕this.scroll.push(this.getBullet(item, i % this.tunnel));return true;}return false;}
}

性能

下面使用 chrome 开发者工具检测的截图,可以看到弹幕可以稳定到 60 fps。

canvas 实现起来比较简单,在现代浏览器上也比较流畅。

如果这么简单就找到了这么流畅的方法,那也太小瞧IE了,在IE``Edge浏览器上这个版本会有一点卡顿,虽然没有第一种方法那么严重,但还是影响观影。

为了在IE上也能流畅的发射弹幕,就需要使用下面这个方法。

完整代码 @rplayer/danmaku

transform 和 transition 版本

这个版本很接近纯 CSS 的方式,将弹幕的滚动交给transition。这个版本与canvas版本有比较大的区别,而且比canvas版本复杂的多。

原理

这个版本并不是利用requestAnimationFrame,而是使用 video 元素的timeupdate事件,在该事件的回调函数中执行与canvas版本相同的步骤。

每个弹幕都有一个开始时间和结束时间,是播放视频的具体时间点。当视频播放到弹幕的开始时间时,就给弹幕设置 transition属性,时长等于弹幕的结束时间减去开始时间。这样浏览器就自动帮我们执行弹幕滚动动画。并且监听弹幕的 transitionend 事件,当它触发时回收弹幕。

使用这种方法也让弹幕与视频时间轴绑定在一起。

原理

这个版本并不是利用requestAnimationFrame,而是使用video元素的timeupdate事件,在该事件的回调函数中执行与canvas版本相同的步骤。

每个弹幕都有一个开始时间和结束时间,是播放视频的具体时间点。当视频播放到弹幕的开始时间时,就给弹幕设置 transition属性,时长等于弹幕的结束时间减去开始时间。这样浏览器就自动帮我们执行弹幕滚动动画。并且监听弹幕的 transitionend 事件,当它触发时回收弹幕。

使用这种方法也让弹幕与视频时间轴绑定在一起。

实现

首先来看看 timeupdate 的回调函数,它与canvasrequestAnimationFrame非常类型。

private onTimeUpdate = (): void => {if (this.player.paused) return;const time = this.player.currentTime;const items = this.load(time);if (!items && !this.bullets.length) return;if (items) {for (let i = 0, l = items.length; i < l; i++) {if (!this.insert(items[i], time, i)) break;}}const bullets: Bullet[] = [];let bullet: Bullet;for (let i = 0, l = this.bullets.length; i < l; i++) {bullet = this.bullets[i];if (bullet.update(time)) { // update 方法返回 true 代表可以回收this.recycleBullet(bullet);} else {bullets.push(bullet);}}this.bullets = bullets;
};

为了弹幕之间不重叠,弹幕的结束时间要根据它前一个弹幕的结束时间来计算。通过上面的提到的公式,弹幕的时长的就等于

let duration = (player.width + this.width) / (player.width / (prevBullet.endTime - this.startTime))
if (duration < 5) duration = 5 // 每个弹幕最少显示 5 秒
this.endTime = this.startTime + duration

为了知道下一个弹幕的开始时间,我们需要知道弹幕完全展示的时间点,也就是弹幕的右侧刚好与播放器的右侧接触的时间点。只有前一个弹幕完全展示出来,后一个弹幕才能开始。

弹幕再加个 showTime 属性代表它完全展示的时间。

this.showTime = this.startTime + (this.width * duration) / (player.width + this.width) + 0.2;

通过 showTime 就可以知道下一个弹幕的开始时间,后面加0.2秒,为了让每个弹幕之间有点间隙。

private insert(item: Item, time: number, i = 0, force?: boolean): boolean {const [tunnel, prevBullet] = this.getShortestTunnel();if (!prevBullet || prevBullet.showTime <= time + 2) {this.scroll[tunnel] = this.getBullet(item, tunnel, time, prevBullet);this.bullets.push(this.scroll[tunnel]);item = undefined;}if (!item) return true;if (this.opts.unlimited || force) {this.bullets.push(this.getBullet(item, i % this.tunnel, time));return true;}return false;
}

getShortestTunnel 获取 showTime 最短的一个隧道,这里设置只要这个隧道在未来两秒可以完全展示,就可以新增弹幕在这个隧道。

了解了 insert 方法下面来看,弹幕的 update 方法。

update(time: number): boolean {if (this.canRecycle) return true;if (this.running || this.startTime > time) return false;this.startTime = time;this.setTransition(this.endTime - time);this.dom.offsetTop;this.setTransform(player.width + this.width);this.running = true;
}

如果还没到开始时间或已经在运行,就直接返回,否则就设置 transition 和 transform 来发射弹幕。

上面的代码就已经可以顺利运行弹幕了。但是如果视频暂停了呢?这个版本并不可以取消定时器来暂停动画。

player.on('pause', () => {const time = player.currentTime;this.bullets.forEach((b) => b.pause(time));
});

在 Danmaku 类中监听暂停时间,并执行所有运行弹幕的 pause 方法,在 pause 方法中我们需要计算当前的弹幕已经走了对少距离,并设置 transform,然后把 transition 设置为 0 来停止动画。

pause(time: number): void {if (time <= this.startTime || this.endTime <= time) {return;}const x =(this.length / (this.endTime - this.startTime)) *(time - this.startTime) + this.lastX;// length 初始值为 player.width + this.widththis.setTransform(x);this.setTransition(0);this.length = player.width - x + this.width;this.lastX = x;this.running = false;
}

当视频恢复播放时会触发 timeupdate 事件,所以无需监听 play 事件。

性能

如果直接用上面代码在 IE 浏览器中运行,会发现有非常明显的卡顿现象,这是因为少了几个 CSS 属性。

will-change: transform; 告知浏览器该元素会有哪些变化的方法,这样浏览器可以在元素属性真正发生变化之前提前做好对应的优化准备工作。因为弹幕一出生就会设置transform属性,所以这个属性非常适合它。

为了启用硬件加速设置 transform 时,设置 x,y,z 三个值。而不是 translateX这样并不能启用硬件加速。我们需要设置成 translateX(${player.width + this.width}px) translateY(0) translateZ(0)。还可以设置 perspective 和 backface-visibility 防止可能出现的动画闪动。

下面是 chrome 开发者工具的截图

这种方法可以在 IE Edge 上流畅的运行,但是到了firefox上有时可能会出现一点点小卡顿,影响并不大。canvas 版本在 firefox 上表现要比这种方法好一点点。

完整代码 @rplayer/danmaku

比较

方法 描述
canvas 版本 实现简单,可以流畅在主流的现代浏览器。缺点就是在 IE Edge 上有点卡顿
transform 和 transition 版本 实现复杂,可以流畅大部分浏览器包括 IE Edge,但在 firefox 有时会不如 canvas 版本流畅

transformtransition 版本是比 canvas 更好的选择,它在大部分浏览器上都可以流畅运行,而且对于弹幕种添加图片或一些其他特效的情况下 css 实现也更加简单。

总结

这里介绍了三种方法,第一种方法虽然有 canvas 版本的实现简单和 css 灵活性,但是非常卡顿。如果不嫌麻烦的话 canvas版本和 transform,transition 版本也可以同时实现,firefox 中使用 canvas 版本,否则使用 transform,transition 版本。

全文完


以下文章您可能也会感兴趣:

  • 一个 AOP 缓存失效问题的排查

  • 小程序开发的几个好的实践

  • RabbitMQ 如何保证消息可靠性

  • 在 SpringBoot 中使用 STOMP 基于 WebSocket 建立 BS 双向通信

  • 聊聊Hystrix 命令执行流程

  • SpringFox 源码分析(及 Yapi 问题的另一种解决方案)

  • Mysql 的字符集以及带来的一点存储影响

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

WEB 视频开发系列——千万级流量弹幕相关推荐

  1. 如何打造一个抗住千万级流量短信服务(续)

    前言 在之前写过一篇博文<短信服务设计>当时讲述了设计的思路,有很多读者朋友反馈说想了解具体的设计思路:今天又重新回顾下当时的具体实现细节发现当时实现的还是有一些巧妙的地方,值得大家参考, ...

  2. 循序渐进学.Net Core Web Api开发系列【14】:异常处理

    循序渐进学.Net Core Web Api开发系列[14]:异常处理 参考文章: (1)循序渐进学.Net Core Web Api开发系列[14]:异常处理 (2)https://www.cnbl ...

  3. Web Control 开发系列(三) 解析IPostBackEventHandler和WebForm的事件机制

    WebForm最大的魅力大概就是它自己的一套事件处理机制了,要做一个好的Control,必须深入理解这套机制,只有这样才可以让我们的Control有一整套Professional的Event,而IPo ...

  4. 老板让你抗住千万级流量,如何做架构设计?

    来源:cnblogs.com/GodHeng/p/8834810.html 随着互联网的发展,各项软件的客户量日益增多,当客户量达到一定峰值时,当数以万计的流量来临时,程序的顺利运行以及即时响应则显得 ...

  5. 设计抗住千万级流量的架构思路(转)

    设计抗住千万级流量的架构思路(转) 随着互联网的发展,各项软件的客户量日益增多,当客户量达到一定峰值时,当数以万计的流量来临时,程序的顺利运行以及即时响应则显得尤为重要,就像双11那天的淘宝一样.那么 ...

  6. 循序渐进学.Net Core Web Api开发系列【7】:项目发布到CentOS7

    系列目录 循序渐进学.Net Core Web Api开发系列目录 本系列涉及到的源码下载地址:https://github.com/seabluescn/Blog_WebApi 一.概述 本篇讨论如 ...

  7. WEB 视频开发-主流协议 HLS DASH

    作者 | 翁鹏 42 上篇文章介绍了 MSE 来播放流媒体,但是 WEB 视频开发并不只依靠 MSE.这篇文章就来介绍主流的两种协议 HLS 和 DASH,以及如何制作并使用支持这些协议开源的客户端库 ...

  8. 【音视频开发系列】一学就会,快速掌握音视频开发的第一个开源项目FFmpeg

    快速掌握音视频开发的第一个开源项目:FFmpeg 1.为什么要学FFmpeg 2.FFmpeg面向对象思想分析 3.FFmpeg各种组件剖析 视频讲解如下,点击观看: [音视频开发系列]一学就会,快速 ...

  9. 【音视频开发系列】盘点音视频直播RTSP/RTMP推流一定会遇到的各种坑,教你快速解决

    聊聊RTSP/RTMP推流那些坑 1.推流架构分析 2.推流缓存队列的设计 3.FFmpeg函数阻塞问题分析 [音视频开发系列]盘点音视频直播一定会遇到的各种坑,教你快速解决 更多精彩内容包括:C/C ...

最新文章

  1. C++中的类属(泛型)机制——模板
  2. 【基础知识】win10常用快捷键
  3. Python函数式编程-map/reduce
  4. DNN 4.6.2的中文语言包
  5. svpwm仿真_三相三线逆变_并网仿真建模
  6. linux分区 挂盘,linux分区,挂盘,LVM
  7. drawboard pdf拆分文件_掌握在线PDF拆分技巧,从此打开文件不再处于“加载中”...
  8. eclipse怎么修改java的行高_eclipse皮肤怎么修改 eclipse皮肤修改教程
  9. 微软职位内部推荐-Software Development Engineering II
  10. 微软 smtp 服务器,配置 SMTP 服务器
  11. 苹果小企业项目申请App Store Small Business Program
  12. DSP28335 ecap使用
  13. Python智力问答小游戏
  14. 使用auto.js模拟手动点击芭芭农场任务(芭芭农场自动脚本2022.8.1更新)
  15. ajax获取jsp数据,如何使用ajax调用从servlet到jsp获取arraylist数据
  16. 朋友圈祝自己生日快乐的文案
  17. 关于学校熄灯时间的调研
  18. IIS应用程序池高级设置各参数详解
  19. ICASSP 2022 | 标点恢复——一套可以同时服务单模态和多模态文本的标点恢复框架
  20. 从《隆中对》中,挖掘对数据分析的一些启示

热门文章

  1. 线路优化模型算法matlab,物流配送线路优化Matlab算法研究
  2. ARP的欺骗泛洪攻击的防御——DAI动态ARP监控技术、
  3. 记录一次爬虫接单项目【采集国际淘宝数据】
  4. CentOS 7 下关闭和开启防火墙
  5. mac系统安装yarn,配置淘宝镜像失败的问题
  6. 深度 | 黑客 Only_Guest 讲述:如何优雅地手刃骗子?
  7. 腾讯云COS学习笔记
  8. 基于Python-Opencv实现哈哈镜效果
  9. springboot项目之电影预告
  10. 手绘板的制作——画布缩放(4)