弹幕组件分为dom型,canvas型和WebGL型,三种类型有着不同的特征和应用场景,接下来将分别介绍这三种弹幕组件:
dom型
dom型组件的每条弹幕都是一个dom结构,将dom挂载在外层容器下。DOM本身易于操作,有比较丰富的api,定义样式和动画,较为方便,通过CSS3\requestAnimationFrame等即可完成。但使用DOM操作存在自动重绘的弊端,若存在大量的弹幕即大量的DOM,会增加内存消耗,同时也会使重绘变的卡顿。
dom型弹幕组件实现思路如下:
1.将容器拆分成多行”隧道”,每行隧道都有相应的状态(例如有弹幕正在过渡时为‘过渡中’,没有弹幕时为‘空闲’)可以装下一个或多个带有过渡效果的弹幕dom,初始化隧道会在实例constructor阶段完成:
constructor(ele, opts = {}) {
// 更新默认配置项
this.options = Object.assign(this.options, opts);
const { trackHeight } = this.options;
// 设置弹幕目标
if (typeof ele === 'string') {
this.target = document.querySelector(ele);
if (!this.target) {
throw new Error('The display target does not exist');
}
} else if (ele instanceof HTMLElement) {
this.target = ele;
} else {
throw new Error('The display target of the barrage must be set');
}
// 初始化跑道,全部是空闲状态
const { height } = this.target.getBoundingClientRect();
this.tracks = new Array(Math.floor(height / trackHeight)).fill('idle');
// 屏幕目标必须具备的CSS样式
const { position } = getComputedStyle(this.target);
if (position === 'static') {
this.target.style.position = 'relative';
// this.target.style.overflow = 'hidden';
}
// 插入css animation
initBulletAnimate(this.target);
}
这里的initBulletAnimate方法即是将过渡动画应用到容器上
const initBulletAnimate = screen => {
if (!screen) {
return;
}
const animateClass = 'BULLET_ANIMATE';
let style = document.createElement('style');
style.classList.add(animateClass);
document.head.appendChild(style);
let { width } = screen.getBoundingClientRect();
let from = `from { visibility: visible; transform: translateX(${width}px); }`;
let to = `to { visibility: visible; transform: translateX(-100%); }`;
style.sheet.insertRule(`@keyframes RightToLeft { ${from} ${to} }`, 0);
};
insertRule方法传送门:developer.mozilla.org/zh-CN/docs/…
我们可以理解初始化时做了如下事情:1.初始化默认配置 2.绑定弹幕容器目标 3.初始化弹幕跑道 4.植入动画样式。
2.如果需要新增弹幕,利用实例上的push方法在实例内部选择一条空闲的隧道添加该弹幕。
push方法:
push(item, opts = {}) {
const options = Object.assign({}, this.options, opts);
const { onStart, onEnd, top } = options;
const bulletContainer = getContainer({
...options,
currScreen: this
});
// 加入当前存在的弹幕列表
this.bullets.push(bulletContainer);
console.log('push before queues', this.queues, this.tracks);
const currIdletrack = this._getTrack();
if (currIdletrack === -1 || this.allPaused) {
// 考虑到全部暂停的情景
this.queues.push([item, bulletContainer, top]);
} else {
this._render(item, bulletContainer, currIdletrack, top);
}
if (onStart) {
// 创建一个监听弹幕动画开始的事件
bulletContainer.addEventListener('animationstart', () => {
if (onStart) {
onStart.call(null, bulletContainer.id, this);
}
});
}
// 创建一个监听弹幕动画完成的事件
bulletContainer.addEventListener('animationend', () => {
// 如果设置了动画完成自定义函数,则执行
if (onEnd) {
onEnd.call(null, bulletContainer.id, this);
}
// 从集合中剔除
this.bullets = this.bullets.filter(function (obj) {
return obj.id !== bulletContainer.id;
});
ReactDOM.unmountComponentAtNode(bulletContainer);
bulletContainer.remove();
});
// 返回该容器的ID
return bulletContainer.id;
}
3.弹幕渲染机制:决定什么时候渲染弹幕,有多个待渲染弹幕时的渲染顺序与渲染位置。
render方法:
_render = (item, container, track, top) => {
this.target.appendChild(container);
const { gap, trackHeight } = this.options;
// 弹幕渲染进屏幕
ReactDOM.render(
React.isValidElement(item) || typeof item === 'string' ? (
item
) : isPlainObject(item) ? (
<StyledBullet {...item} />
) : null,
container,
() => {
let trackTop = track * trackHeight;
container.dataset.track = track;
container.style.top = top ? top : `${trackTop}px`;
let options = {
root: this.target,
rootMargin: `0px ${gap} 0px 0px`,
threshold: 1.0
};
let observer = new IntersectionObserver(entries => {
console.log('!entries',entries)
entries.forEach(entry => {
// 完全处于视窗之内
const { intersectionRatio, target } = entry;
console.log('bullet id', target.id, intersectionRatio);
if (intersectionRatio >= 1) {
let trackIdx = target.dataset.track;
console.log('curr track value', this.tracks[trackIdx]);
console.log('curr queues', this.queues);
if (this.queues.length) {
const [item, container, customTop] = this.queues.shift();
this._render(item, container, trackIdx, customTop);
} else {
this.tracks[trackIdx] = 'feed';
}
}
});
}, options);
observer.observe(container);
}
);
};
这里介绍下IntersectionObserver:www.jianshu.com/p/84a86e41e…
IntersectionObserver监听目标元素与其祖先或视窗交叉状态的手段,本质就是利用重叠观察一个元素是否在视窗可见。这段代码中利用了IntersectionObserver中的intersectionRatio这一属性大于等于一时作为弹幕完全进入屏幕的依据,当目前弹道下的弹幕组件完全显示在屏幕中时,就可以从弹幕队列中取出第一个继续执行render方法了。
4.暂停与恢复:
首先要解释一下为什么要做暂停和恢复,主要是两个方面的考虑。
第一个考虑是浏览器的兼容问题。弹幕渲染流程会频繁调用到 JS 的 setTimeout 以及 CSS 的 transition:标签页切到后台,则弹幕暂停,切到前台再恢复:
let hiddenProp, visibilityChangeEvent;
if (typeof document.hidden !== 'undefined') {
hiddenProp = 'hidden';
visibilityChangeEvent = 'visibilitychange';
} else if (typeof document.msHidden !== 'undefined') {
hiddenProp = 'msHidden';
visibilityChangeEvent = 'msvisibilitychange';
} else if (typeof document.webkitHidden !== 'undefined') {
hiddenProp = 'webkitHidden';
visibilityChangeEvent = 'webkitvisibilitychange';
}
document.addEventListener(visibilityChangeEvent, () => {
if (document[hiddenProp]) {
this.pause();
} else {
// 必须异步执行,否则恢复后动画速度可能会加快,从而导致弹幕消失或重叠,原因不明
this._resumeTimer = setTimeout(() => { this.resume(); }, 200);
}
}, false);
先看下暂停滚动的主要代码(注意已滚动路程 rolledDistance,将用于恢复播放和防重叠):
this._eachDanmakuNode((node, y, id) => {
const data = this._findData(y, id);
if (data) {
// 获取已滚动距离
data.rolledDistance = -getTranslateX(node);
// 移除动画,计算出弹幕所在的位置,固定样式
node.style.transition = '';
node.style.transform = `translateX(-${data.rolledDistance}px)`;
}
});
接下来是恢复滚动的主要代码:
this._eachDanmakuNode((node, y, id) => {
const data = this._findData(y, id);
if (data) {
// 重新计算滚完剩余距离需要多少时间
data.rollTime = (data.totalDistance - data.rolledDistance) / data.rollSpeed;
data.startTime = Date.now();
node.style.transition = `transform ${data.rollTime}s linear`;
node.style.transform = `translateX(-${data.totalDistance}px)`;
}
});
this._render();
参考项目:rc-bullets-master
github地址:github.com/zerosoul/rc…
呈现效果
通过 DOM 元素实现弹幕,前端同学可以很方便地通过 CSS 修改弹幕样式。同时,得益于浏览器原生的 DOM 事件机制,借助这个可以很快捷实现一系列弹幕交互功能:个性化、点赞、举报等,以满足产品的各种互动需求。很容易看到,目前像腾讯视频、爱奇艺等都是通过 DOM 元素实现弹幕,这是目前主流的实现方式。
canvas型
canvas型弹幕组件实现思路如下:
首先在实例化组件时:
constructor({
container,
data = [],
config = {},
avoidOverlap = true,
mask = [],
beforeRender = () => {},
afterRender = () => {},
}) {
// 获取父级容器
this.parent =
typeof container === 'string'
? document.getElementById(container)
: container;
this.parent.classList.add('barrage-container');
// 创建画布
this.canvas = document.createElement('canvas');
this.canvas.className = 'barrage-canvas';
this.canvas.width = this.parent.clientWidth;
this.canvas.height = this.parent.clientHeight;
this.canvas.style.pointerEvents = 'none'; // canvas 事件穿透
this.canvas.style.letterSpacing = '1.5px'; // canvas 字符间距
this.parent.appendChild(this.canvas);
// 若父节点存在其他子节点,则设置画布为绝对定位
if (this.parent.childNodes.length > 1) {
this.parent.style.position = 'relative';
this.canvas.style.position = 'absolute';
this.canvas.style.left = '0px';
this.canvas.style.top = '0px';
}
// 画布上下文
this.ctx = this.canvas.getContext('2d');
// 弹幕装填时是否启用布局优化
this.avoidOverlap = avoidOverlap;
// 全局参数设置
this.setConfig({
...DEFAULT_CONFIG,
...config,
});
this.setMask(mask); // 设置蒙版
this.beforeRender = beforeRender;
this.afterRender = afterRender;
// 数据初始化
this.setData(data);
}
该项目中与dom型项目不同的是加入了蒙版效果功能,且可以从初始化中发现有两个生命周期函数:beforeRender和afterRender,beforeRender方法是为了渲染前读取画布 vCanvas 的数据,并处理为蒙版图像,详细见调用函数:
barrage.beforeRender = async() => {
// 读取图像
const frame = vContext.getImageData(0, 0, vCanvas.width, vCanvas.height);
// await barrage.loop(frame)
// 图像总像素个数
const pxCount = frame.data.length / 4;
// 将 frame 构造成我们需要的蒙版图像
for (let i = 0; i < pxCount; i++) {
// 这里不用 ES6 解构赋值的写法,主要为了保证计算性能
const r = frame.data[i * 4 + 0];
const g = frame.data[i * 4 + 1];
const b = frame.data[i * 4 + 2];
// 将黑色区域以外的内容设为透明
if (r > 15 || g > 15 || b > 15) {
frame.data[4 * i + 3] = 0;
}
}
// 设置蒙版
barrage.setMask(frame);
};
这里进行弹幕初始化时是采用了取简的方式,仅在背景色为黑色的情况下把除黑色以外的区域隐藏掉。然后触发设置蒙版方法:详细见setMask方法:
setMask(input) {
if (typeof input === 'string') {
GLOBAL_MASK.type = 'url';
loadImage(input).then(img => {
GLOBAL_MASK.data = img;
});
} else if (
Object.prototype.toString.apply(input) === '[object ImageData]'
) {
GLOBAL_MASK.type = 'ImageData';
GLOBAL_MASK.data = input;
} else {
GLOBAL_MASK.type = null;
GLOBAL_MASK.data = null;
}
}
其中loadImage方法如下:
export const loadImage = url =>
new Promise((resolve, reject) => {
if (loadImageCache[url]) {
resolve(loadImageCache[url]);
} else {
const picture = new Image();
picture.src = url;
picture.onload = () => {
loadImageCache[url] = picture;
resolve(picture);
};
picture.onerror = () => {
reject();
};
}
});
生成图片url后传递给蒙版信息GLOBAL_MASK,它的定义如下:
// 蒙版信息
const GLOBAL_MASK = {
type: null, // 蒙版类型:'url' 'ImageData'
mask: null, // 蒙版数据:ImageData
};
随后执行afterRender方法:
// 实时绘制视频到画布
barrage.afterRender = () => {
vContext.drawImage(video, 0, 0, vCanvas.width, vCanvas.height);
};
最后执行setData方法:
setData(data) {
// 保存上一版本数据集
if (this.data) this.prevData = this.data;
// 获取弹幕数据并计算出布局信息
this.data = layout({
config: this.config,
canvas: this.canvas,
data,
avoidOverlap: this.avoidOverlap,
});
// 不更改上一版本数据集中已存在的数据
this.data.forEach(item => {
if (this.prevData && this.prevData.some(d => d.key === item.key)) {
const prevItem = this.prevData.find(d => d.key === key);
Object.assign(item, prevItem);
}
});
}
layout方法是组件核心方法:
export const layout = ({ config, canvas, data, avoidOverlap = false }) => {
// 获取画布上下文
const canvasContext = canvas.getContext('2d');
// 弹幕布局
canvasContext.font = `${config.fontSize}px ${config.fontFamily}`;
// 弹幕数据按时间排序
const listSortedByTime = data.sort((a, b) => a.time - b.time);
// 计算弹幕布局
const initialList = listSortedByTime.map(
({
key,
time,
text,
fontSize = config.fontSize,
fontFamily = config.fontFamily,
color = config.defaultColor,
createdAt = new Date().toISOString(),
avatar,
avatarSize,
avatarMarginRight,
}) => {
// 计算文本宽度
const { width } = canvasContext.measureText(text);
const details = {
key,
time,
avatar: avatar || null,
avatarSize: avatarSize || (avatar ? 1.2 * fontSize : 0),
avatarMarginRight: avatarMarginRight || (avatar ? 0.2 * fontSize : 0),
text,
fontSize,
fontFamily,
color,
createdAt,
left: (config.speed * time) / 1000 + canvas.width,
width,
height: fontSize,
randomRatio: Math.random(),
visible: false,
};
details.width += details.avatarSize + details.avatarMarginRight;
return details;
}
);
// 计算跑道数
const rowCount = Math.floor(
canvas.height / (config.lineHeight * config.fontSize)
);
// 创建跑道(跑道个数为 rowCount)
const tracks = {};
new Array(rowCount).fill(0).forEach((n, i) => {
tracks[i] = [];
});
// 填充跑道
for (let i = 0; i < initialList.length; i++) {
const item = initialList[i];
if (!avoidOverlap) {
// 若不禁止弹幕重叠,则随机分配跑道
const randomTrackIdx = Math.floor(rowCount * Math.random());
item.top =
(config.fontSize * (config.lineHeight - 1)) / 2 +
randomTrackIdx * config.lineHeight * config.fontSize;
item.visible = true;
tracks[randomTrackIdx].push(item);
} else {
// 寻找合适的跑道编号
const suitableTrackIdx = Object.values(tracks).findIndex(t => {
const { left = canvas.width, width = 0 } = t[t.length - 1] || {};
return left + width + MIN_SEP * canvas.width < item.left;
});
// 若存在合适的跑道,则放置弹幕
if (suitableTrackIdx >= 0) {
item.top =
(config.fontSize * (config.lineHeight - 1)) / 2 +
suitableTrackIdx * config.lineHeight * config.fontSize;
item.visible = true;
const suitableTrack = tracks[suitableTrackIdx];
const lastItem = suitableTrack[suitableTrack.length - 1];
if (lastItem) {
item.prev = lastItem;
lastItem.next = item;
}
tracks[suitableTrackIdx].push(item);
}
}
}
// 返回可视的弹幕数据集
return initialList.filter(x => x.visible);
};
这里计算布局配置采用的依然是跑道系统,依据是否可以弹幕重叠来选择不同的跑道分配规则。
最后通过_render方法渲染到画布上:
async _render() {
// 弹幕整体向左移动的总距离
const translateX = (this.config.speed * this.progress) / 1000;
// 清空画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 筛选待渲染的数据
let dataShown = this.data
.filter(
x =>
x.left + x.width - translateX >= -2 * MIN_SEP * this.canvas.width &&
x.left - translateX < (1 + 2 * MIN_SEP) * this.canvas.width
)
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
// 是否禁止重叠
if (this.avoidOverlap) {
dataShown = dataShown.filter(x => !x.hasOverlap);
}
// 执行渲染前的回调
if (this.beforeRender)
this.beforeRender(this.ctx, this.progress, this.animState);
this.ctx.save();
if (GLOBAL_MASK.data) {
if (GLOBAL_MASK.type === 'ImageData') {
this.ctx.putImageData(GLOBAL_MASK.data, 0, 0);
// console.log('!this.ctx',this.ctx)
} else if (GLOBAL_MASK.type === 'url') {
this.ctx.drawImage(
GLOBAL_MASK.data,
0,
0,
this.canvas.width,
this.canvas.height
);
}
if (!this.anotherCanvas) {
this.anotherCanvas = document.createElement('canvas');
this.anotherCanvas.width = this.canvas.width;
this.anotherCanvas.height = this.canvas.height;
this.anotherContext = this.anotherCanvas.getContext('2d');
} else {
this.anotherContext.clearRect(
0,
0,
this.anotherCanvas.width,
this.anotherCanvas.height
);
}
}
// 绘制数据
const context = GLOBAL_MASK.data ? this.anotherContext : this.ctx;
context.globalAlpha = this.config.opacity;
context.shadowColor = 'rgba(0, 0, 0, 1)';
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = this.config.textShadowBlur * 2;
context.textBaseline = 'top';
dataShown.forEach(d => {
const left =
d.left -
(translateX +
this.canvas.width *
d.randomRatio *
2 *
MIN_SEP *
Math.sin((Math.PI * translateX) / this.canvas.width));
if (d.avatar && typeof d.avatar === 'string') {
context.drawImage(
makeImageElement(d.avatar),
left,
d.top - (d.avatarSize - d.fontSize) / 2,
d.avatarSize,
d.avatarSize
);
}
context.font = `${d.fontSize}px ${d.fontFamily}`;
context.fillStyle = d.color;
context.fillText(
d.text,
left + d.avatarSize + d.avatarMarginRight,
d.top
);
});
if (GLOBAL_MASK.data) {
this.ctx.globalCompositeOperation = 'source-in';
this.ctx.drawImage(
this.anotherCanvas,
0,
0,
this.canvas.width,
this.canvas.height
);
}
this.ctx.restore();
// 执行渲染后的回调
if (this.afterRender)
this.afterRender(this.ctx, this.progress, this.animState);
// 执行下一帧
if (this.animation) requestAnimationFrame(() => this._render());
}
其中关键api:requestAnimationFrame传送门:www.jianshu.com/p/fa5512dfb…
requestAnimationFrame 会把每一帧中的所有DOM操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率,一般来说,这个频率为每秒60帧,也就是每秒触发60次_render方法,这就使得连续生成的图片能拼接成一个连续的视频
另一关键属性:globalCompositeOperation传送门:blog.csdn.net/laijieyao/a…
将globalCompositeOperation设置为’soure in’的意义在于使得目标重叠的区域只绘制源,而不重叠的部分变成透明的。到此实现蒙版效果的原理就明晰了:先用视频video作为底板,扣除人物的黑色区域设置为第二层,弹幕设置为第三层,第三层弹幕与第二层黑色刨除人物的底板进行复合,在复合区域只绘制弹幕,镂空的区域正好把底板的video人物呈现出来,这就是实现蒙版效果的基本思路。
此处除了通过实现外,还可以通过css的mask属性blog.csdn.net/qq_44607694…
实现效果如下:
webGL型
webgl实际上用了canvas的一个3d渲染上下文,在绘制平面内容时,和canvas 2d相比,webgl更为直接的利用了gpu硬件,在某些场合,几乎可以摆脱cpu的限制,达到性能极致。
webGL实现的弹幕组件有着更高的自由度,能实现一些特效,例如:
webGL实现思路如下:
每次生成弹幕时会进入一个工厂模式的方法中:
_danmakuFactory (text, opt){
if (!DANMAKU_TYPES.some(v => v = opt.mode)) return console.warn('not defined this mode..')
return {
curve (){
return new CurveDanmaku(text, opt)
},
linear (){
return new LinearDanmaku(text, opt)
},
fixed (){
return new FixedDanmaku(text, opt)
}
}[opt.mode]()
}
以该工厂函数的CurveDanmaku为例:
export class CurveDanmaku extends Danmaku {
constructor(text, opt) {
super(text, opt);
var message = new SplitText(text, opt);
this.addChild(message);
this.message = message;
this.pivot.x = this.width * 0.5;
this.pivot.y = this.height * 0.5;
}
move({ duration = 10000 } = {}) {
if (this.tw_final) this.tw_final.cancel();
var by = this.y;
// Math.PI*8 / 3000
var N = 0;
var dn = 0.03 + Math.random() * 0.03;
this.is_moving = true;
this.tw_final = new TWEEN.Tween({ x: this.x, t: 0 })
.to({ x: this.final_po.x, t: 1 }, duration)
.onUpdate(({ x, t }) => {
this.x = x;
this.y = by;
N += dn;
this.message.children.forEach((piece_text, i) => {
var nn = N - i * 0.2;
piece_text.rotation = Math.atan2(-Math.cos(nn), 1);
piece_text.y = Math.sin(nn) * 40;
});
})
.start();
}
}
函数内的动画效果使用的TWEEN.js 见 github.com/tweenjs/twe… ,tween.js是个功能强大的动画类库,他能让你在js中编写动画函数,只需给出起始坐标和运行过渡效果即可。
函数中的SplitText函数会生成一个包含着文案信息和坐标信息的弹幕dom,代码如下:
class SplitText extends PIXI.Container {
constructor(
text,
opt = ({
fill = '#ffffff',
fontSize = 16,
wireframe = false,
alpha = 1,
} = {})
) {
super();
[...text].map((v, i) => {
let fontSize = opt.fontSize;
let message = new PIXI.Text(
v,
new PIXI.TextStyle({
fontSize,
fill: opt.fill,
dropShadow: true,
dropShadowColor: '#000000',
dropShadowBlur: 2,
dropShadowAngle: Math.PI / 6,
dropShadowDistance: 1,
})
);
this.addChild(message);
message.pivot.x = 0;
message.x = i * fontSize;
return message;
});
this.pivot.x = this.width * 0.5;
this.pivot.y = this.height * 0.5;
}
}
其中的PIXI正是该组件底层依赖的webGL渲染引擎:
PIXI介绍: pixijs.huashengweilai.com/guide/start…
PixiJS 是一个渲染库,可让您创建丰富的交互式图形、跨平台应用程序和游戏,而无需深入研究 WebGL API 或处理浏览器和设备兼容性问题。
初始化后的弹幕dom已经包含了文案信息,只需给它设置起始位置,并调用工厂函数的move方法即可使其在和PIXI硬件引擎加速的加持下通过tween.js以动画的形式运行起来。
var danmaku = this._danmakuFactory(text, opt)
this._giveDanmakuStartPo(danmaku, danmaku.__idx__, danmaku.parent.danmakus)
this._giveDanmakuEndPo(danmaku, danmaku.__idx__, danmaku.parent.danmakus)
danmaku.y = runway.default_height * .5
danmaku.move()
dom和canvas 2d和webgl的性能对比
让我们用实际的例子来测试下三者的性能区别:渲染大量的精灵粒子。
- dom
wow.techbrood.com/uploads/210…
- canvas (2d)
wow.techbrood.com/uploads/210…
- webgl
wow.techbrood.com/uploads/210…
在i7独显笔记本上,当粒子数量超过5000的时候,
dom的fps掉到10几了。
canvas勉强还有20+,
而webgl仍然保留在30+。
由此可以看出当弹幕较少时可以使用dom型或canvas型,当弹幕较多时适合使用webgl型。