本期作者
臧至聪
哔哩哔哩资深开发工程师
负责B站移动端弹幕相关业务,致力于持续探索更多更新奇更有趣的创意交互形式。
背景
哔哩哔哩移动端日均活跃用户超过5千万,日均视频播放量超过12亿次,用户日均使用时长达到了75分钟。在越来越多新用户涌入哔哩哔哩的同时,提高视频的互动性和趣味性也变得至关重要。弹幕作为一种观看视频的交互形式,同时也是B站的一个非常重要的标签,深受广大用户的喜爱。作为业内弹幕领域的标杆,B站在近段时间内推出了多种全新的弹幕类交互产品,如投票弹幕、关注三连弹幕、烟花特效弹幕、应援弹幕等。
这些弹幕的背后都离不开Chronos引擎——一款B站自研的移动端跨平台的渲染引擎。它可以运行在Android、iOS等多个平台之上,使用TypeScript作为业务开发语言,并提供了丰富的API,使业务层能够非常便捷高效地实现文字、图形的渲染及动画。
今天就来介绍一下基于Chronos引擎,我们最新推出的一款全新的弹幕交互产品——应援弹幕。
应援弹幕
视频中通常会出现一些高潮或情绪渲染力较强的场景和镜头,这些场景将调动用户情绪、引起用户共鸣。目前用户主要通过发弹幕、跟队形等方式来表达情感和共鸣,形式比较单一。现推出一款应援弹幕,将用户发送的弹幕与当下视频场景强相关的文字或图片组合,由此打造视频定制化的弹幕体验,以独特性和新鲜感刺激用户发弹幕,贴合视频内容表达情感,增强互动趣味性。
整体方案
对于应援弹幕来说,我们不光要能够绘制文字,还要根据给定的背景底图将文字填充进去,达到一种类似于词云的效果。同时对于文字的填充,需要有动画效果来营造一种填充感,用以激发用户参与互动的热情。
关于应援弹幕中词云的效果的实现,有两种方案:
1. 客户端每次展示前,根据该条应援弹幕中的背景图及弹幕数据,实时计算出弹幕的布局后,再展示给用户;
2. 服务端将弹幕的布局信息提前计算完成,然后把计算结果连同数据部分一并发送给客户端,客户端根据服务端的数据及计算结果,直接进行展示。
乍一看,方案2会是一个对客户端实现非常友好地方案。但细细想来,这个方案在可能存在以下问题:
a. 众所周知,文字所占区域的大小,与字体和字号有着密不可分的关系。移动端设备众多,且不说不同厂商不同品牌不同型号的手机的字体有区别,有的时候同样的手机,不同的系统版本,字体也有所不同。如何保证服务端计算的布局结果,在所有设备上的渲染效果一致是个比较大的问题。
b. 虽然文字的渲染会受到各种设备条件的影响,在多设备中存在效果不一致的情况,但是图片可以在多端保持渲染效果的一致性。如果服务端事先将应援弹幕的布局结果生成为图片,下发给客户端,就可以保证最终渲染效果的一致性。但是这种整张图片的方式会失去整个应援弹幕局部动画的能力,而且也减少了整个应援弹幕用户交互部分的可玩性。
由于应援弹幕本身就是想要激发用户的互动热情,提升用户对于弹幕的参与度,最终我们还是选择了方案1的方式,由客户端完成实时渲染。
上图是应援弹幕简单的示意流程,其中应援弹幕数据中至少应包含以下信息:
progress:应援弹幕上屏时间点(相对于视频时间)
duration:应援弹幕持续时间
position:应援弹幕显示位置
picture:应援弹幕背景图
dms:应援弹幕中填充的弹幕数据
客户端根据应援弹幕数据中picture和dms的信息可以完成应援弹幕词云效果的动态布局,并根据position、progress和duration的信息实现整个应援弹幕上屏渲染的流程。
由于应援弹幕整体的体验与词云填充效果有着密不可分的关系,一方面越贴合底图的文字填充会带来更好的是视觉效果,另一方面,越快完成布局,尽可能快的将效果展现给用户,能在用户参与交互的过程中,快速的给予用户反馈,提升用户的参与度。如何在移动端实现一个既能满足实时性要求,又有着相对好的视觉体验的词云效果填充,成为了整体实现的一个难点。
词云填充效果实现
要实现词云填充效果,首先需要感知被填充图片的内容,也就是哪些区域可以被弹幕填充,哪些区域无法被弹幕填充。
有一个最简单的办法,就是获取到图片中每个像素点的色值,根据所有像素点的色值,就可以计算出整幅图片中,哪些位置是可以被填充的。
......
image.toPixelData().forEach((pixel, index) => {
if (pixel.alpha <= 0) {
this._filledSet.add(index);
}
});
......
通过对插入弹幕的每个像素点遍历,就可以很轻松地判断弹幕区域是否存在无效位置,当弹幕区域都为有效位置时,则意味着这条弹幕处于一个可以被放置的位置,并将该弹幕所覆盖的所有像素都添加至之前的无效坐标中,循环往复,便能轻松地完成重叠检测。
/**
* Creates a texture from the specified image.
*
* You must call this function while the engine is running
* ([[Window.running]] is `true`), otherwise it will return null.
*
* @export
* @param {Image} image An image.
* @param {(Sampler | null)} [sampler] An object that defines how a texture
* should be sampled.
* @returns {(Texture | null)} A new texture object.
*/
export function createFromImage(
image: Image,
sampler?: Sampler | null
): Texture | null;
void main() {
float alpha = min(texture2D(u_texture, v_tex_coord).a, 0.4);
gl_FragColor = alpha * vec4(0.0, 0.0, 0.0, 1.0);
}
同时我们,将图片转换成纹理并用片元着色器对其简单的处理一下,便可以得到一张用于展示的底图剪影了。
对于文字弹幕而言,我们可以通过光栅化得到一张文字的图片,然后对图片遍历得到一个文字轮廓及填充状态的信息。但对于应援弹幕来说,短时间内需要安置成百条弹幕内容,每次都重复获取文字轮廓及填充状态的信息无疑会带来巨大的时间开销,所以针对这个场景,我们将每条弹幕简化成一个矩形图片来处理。
通过这一系列简化操作,我们将重叠判断简化成遍历矩形范围内的每个点的无效情况。实际情况下,我们对遍历做了一些额外的操作,由外向内的遍历,能大大减小一些极端情况出现的概率。
protected isAvailable(minCol: number, minRow: number, maxCol: number, maxRow: number): boolean {
if (minCol > maxCol || minRow > maxRow) {
return true;
}
for (let col = minCol; col <= maxCol; col++) {
if (
this._filledSet.has(this.positionToIndex(col, minRow)) ||
this._filledSet.has(this.positionToIndex(col, maxRow))
) {
return false;
}
}
for (let row = minRow; row <= maxRow; row++) {
if (
this._filledSet.has(this.positionToIndex(minCol, row)) ||
this._filledSet.has(this.positionToIndex(maxCol, row))
) {
return false;
}
}
return this.isAvailable(minCol + 1, minRow + 1, maxCol - 1, maxRow - 1);
}
既然我们已经将弹幕简化为了一个矩形,那我们是不是能有一些更快的方式来判断重叠呢?答案是肯定的,那就是积分图。
积分图并不是一个真实的图片,在积分图中,每个单元的值,等于原图此位置左上角所有像素值之和,如下图:
积分图的性质能快速的帮我们判断一个区域是不是有内容。
如果一个矩形区域内,所有像素值之和为零,则说明这个区域内没有内容。根据积分图的特征,我们可以进行如下计算:用大矩形所有像素值之和,减去上方和左侧两个矩形像素值之和,再加上左上角小矩形像素值之和,就得到了所求区域内像素值之和。
大矩形(绿色): 10
左侧矩形(紫色): 5
上方矩形(青色): 4
左上矩形(橙色): 0
目标矩形(蓝色): 10 - 5 - 4 + 0 = 1
这样我们只需要4次取值加一次运算即可判断某区域是否为空,比遍历像素要快得多。
......
const pixelCount = this.pixelCount;
this._filledArray = new Uint8Array(pixelCount);
this._integralPixelArray = new Uint32Array(pixelCount);
for (let col = 0; col < this._width; col++) {
for (let row = 0; row < this._height; row++) {
const index = this.positionToIndex(col, row);
this._filledArray[index] = this._colorArray[index][3] > 0 ? 0 : 1;
this._integralPixelArray[index] = this._filledArray[index];
if (col > 0) {
this._integralPixelArray[index] += this._integralPixelArray[this.positionToIndex(col - 1, row)];
}
if (row > 0) {
this._integralPixelArray[index] += this._integralPixelArray[this.positionToIndex(col, row - 1)];
}
if (col > 0 && row > 0) {
this._integralPixelArray[index] -= this._integralPixelArray[this.positionToIndex(col - 1, row - 1)];
}
}
}
......
protected isAvailable(minCol: number, minRow: number, maxCol: number, maxRow: number): boolean {
const totalPixelCount = this._integralPixelArray[this.positionToIndex(maxCol, maxRow)];
const topPixelCount = minRow <= 0 ? 0 : this._integralPixelArray[this.positionToIndex(maxCol, minRow - 1)];
const leftPixelCount = minCol <= 0 ? 0 : this._integralPixelArray[this.positionToIndex(minCol - 1, maxRow)];
const leftTopPixelCount =
minRow <= 0 || minCol <= 0 ? 0 : this._integralPixelArray[this.positionToIndex(minCol - 1, minRow - 1)];
return totalPixelCount - topPixelCount - leftPixelCount + leftTopPixelCount <= 0;
}
}
当我们有了判断重叠的方法后,接着我们需要的是将弹幕一条条的插入底图中。用贪心算法,可以轻松地实现布局:
1. 选择一个初始点
2. 每条弹幕,从初始点开始,判断是否产生重叠,如果重叠则稍微移动一些,直到没有重叠或移出底图
重复这个步骤,就可以实现应援弹幕的布局了。实际过程中,虽然贪心的方法可以实现词云填充的效果,但是面对面积较大的底图及大量弹幕时,会消耗几十秒的时间才能得到结果。这样的时间消耗,对于实际体验来说是难以接受的。上文提到了,之前已经将弹幕近似为了一个矩形的图片,那是不是也可以将底图近似为一组可以安置的候选点呢?
实际的实现过程中,我们采用了矩形螺旋线的方式,以底图的中心点为起点,环形展开固定步幅,依次生成候选点。
通过这样的方式可以极大的减少尝试次数。
function createRectSpiralGenerator(
width: number,
height: number,
dt: number
): (t: number, offset: number) => Position {
let x = 0;
let y = 0;
return function (t: number, offset: number = 0) {
t = t + offset;
const sign = t < 0 ? -1 : 1;
const num = Math.ceil(Math.sqrt(1 + 4 * t * sign) - sign) & 3;
if (num === 0) {
x += dt;
} else if (num === 1) {
y += dt;
} else if (num === 2) {
x -= dt;
} else {
y -= dt;
}
return {
col: x + Math.floor(width / 2),
row: y + Math.floor(height / 2),
};
};
}
......
const generator = createRectSpiralGenerator(this._width, this._height, spiralDelta);
let t = 0;
while (true) {
t += 1;
const pos = generator(t, 0);
if ((pos.col < 0 || pos.col >= this._width) && (pos.row < 0 || pos.row >= this._height)) {
break;
}
if (pos.col < 0 || pos.col >= this._width || pos.row < 0 || pos.row >= this._height) {
continue;
}
const index = this.positionToIndex(pos.col, pos.row);
if (this._colorArray[index][3] <= 0) {
continue;
}
this._availableIndexSet.add(index);
}
......
通过上文的所有优化及实现后,最终能将一个600 * 600底图的词云效果填充及布局耗时压缩到1.5s以内。
异步填充与布局
1.5s 看似不长,但对于一个需要实时渲染的场景来说,可能就是几十个的VSYNC周期了。如果所有的这些填充布局操作都在渲染线程中执行的话,那将会是将近2s的画面卡顿,这样的体验是不能接受的。
为了解决上述问题,我们需要将词云效果的填充与布局移至worker线程执行。渲染线程根据worker返回的执行结果完成上屏的工作即可,这样的话,能够极大地避免因为应援弹幕复杂的布局计算导致渲染卡顿的问题。
但即使将整个过程全部交由worker线程来完成,还是无法避免当出现用户输入后,重新触发填充布局,从输入开始到词云效果的上屏之间会产生一个比较大的时延。
在实际实现过程中,为了尽可能减少由于布局耗时对于用户体验的影响,我们将整个布局任务拆分成了一个个小的task,每当完成了部分的布局之后,就将结果及时交由渲染线程完成上屏的工作。通过将一个耗时任务进行不断的拆分,尽可能实时的给予用户反馈。
......
App.getTaskWorkerBinder()?.sendMsg(
Task.WORD_CLOUD_UPDATE,
{
timeout, count
},
(response, expired) => {
const textureKey: string | null = response.key ?? null;
if (expired) {
cron.TransferCenter.instance.popObjectForKey(textureKey)?.release();
return;
}
const count: number = response.count ?? 0;
const completed: boolean = response.completed ?? false;
const success: boolean = response.success ?? false;
if (completed) {
this.reportShowEventIfNeeded(success, count);
} else {
this.triggerWordCloudEngineUpdate();
}
const texture = cron.TransferCenter.instance.popObjectForKey(textureKey);
this._displayer.addOneGroup(texture, completed, success);
texture.release();
}
);
......
Main
......
const timeout = request.timeout;
const count = request.count;
const results = wordCloudEngine.run(timeout, count);
const img = wordCloudEngine.snapshot(results);
const texture = cron.Texture.createFromImage(img);
const key = `word_cloud_${token++}`;
cron.TransferCenter.instance.pushObjectForKey(key, texture);
texture.release();
response.key = key;
response.completed = wordCloudEngine.completed;
response.success = wordCloudEngine.filledRatio >= DEFAULT_FILLED_RATIO;
response.count = wordCloudEngine.results.length;
......
run(timeout: number = Number.POSITIVE_INFINITY, count?: number): Result<T>[] {
const results: Result<T>[] = [];
const ts = Date.now();
while (!this.completed && Date.now() - ts < timeout) {
const result = this.runOnce();
if (result) {
results.push(result);
if (count <= results.length) {
break;
}
}
}
return results;
}
runOnce(): Result<T> | null {
if (this.completed) {
return null;
}
const model = this._models.next();
const style = this.findAvailable(model);
if (!style) {
return null;
}
const result = {
model,
style,
};
this._results.push(result);
return result;
}
Worker
渲染优化
为了优化渲染效率,并兼顾一些灵动的动画,应援弹幕整体将布局结果分批次合并成多幅纹理,通过纹理的叠加,提升了渲染的效率。同时,在合并过程中,通过混色模式的设置,可以很轻松地实现下图的效果。
最终效果
总结与展望
伴随着Chronos引擎功能的不断强大,在未来,我们会持续探索更多新型的弹幕展现形式及创意交互,增强用户在视频观看过程中的参与度,给用户带来更多更新奇、更有趣的视频观看体验。