- 弹幕不重叠——核心诉求
- 场景简单——弹幕匀速运动
- 性能较好——避免密集的实时弹幕位置计算
针对这里的弹幕重叠问题(不妨说平面中的重叠问题),我们可以从竖直方向和水平方向两方面来分析。
竖直方向
对于典型的弹幕场景,每个弹幕元素作水平直线运动,竖直方向(即纵向)的速度分量为0
。这就意味着:弹幕元素彼此之间在竖直方向没有发生相对运动,因此弹幕在纵向的间距可通过对容器划分「轨道」进行隔离。
轨道好比公路上的单车道,每条单车道只允许一辆车通行。
我们可以使用相同的轨道高度(如下图中h
)对弹幕的竖直方向进行布局规划。通过所在轨道编号
与轨道高度
的乘积计算,可获得每条弹幕距离容器顶部的偏移量(top
值)。
原理上,轨道高度并不一定要大于弹幕高度,取较小的分度值也是没有问题的。轨道可以是一个承载弹幕对象的队列,本身并不需要展示为DOM
实体。
水平方向
在上面的讨论中,通过引入轨道的概念,避免了竖直方向上&不同轨道之间的弹幕重叠干扰。对于水平方向的分析,我们不妨先从简单的单个弹幕元素看起来。
每个弹幕元素会经历这样三个阶段:
- 出生在容器右侧——挂载
- 进入到容器中——运动
-
完全离开容器——移除
在这里,我们需要重新审视下运动的主人公——弹幕君:每一条默默划过的弹幕,实际上包含空间和时间两个维度的描述:
空间维度:包括弹幕君的一些包括宽高、相对位置等几何属性;
时间维度:
startTime 通常用于记录用户生成该弹幕的时刻,这是一个相对开始播放的时间偏移量。该属性指导了弹幕元素的默认出现时机,以及在候选队列中的出场次序。
duration,弹幕在容器中飘过的持续时间。由于弹幕场景中每个弹幕元素的水平位移量是固定的,因此 duration 也间接决定了弹幕的运动速度。
基于数据的视角,一条弹幕运动相关的信息可以表达如下:
// ts表述
interface bullet {
width : number, // 弹幕元素宽度
height : number, // 弹幕元素高度
top : number, // 弹幕元素距离容器顶部的偏移量
left : number, // 弹幕元素距离容器左边缘的偏移量,通常用于设定弹幕元素初始位置
startTime : number, // 弹幕元素相对于开始播放时刻的出现时机
duration: number, // 弹幕元素的展示持续时间
}
具象地说,这里的left
和top
指定了弹幕元素的出生点位;width
和height
标识了弹幕元素的高矮胖瘦;startTime
和duration
则分别决定了弹幕元素在候选列表中的顺序和展示时长。
下面用原生 js 简易描述一条弹幕运动的配置:
// container 表示弹幕展示的容器
const containerPos = container.getBoundingClientRect();
const {
left: containerLeft,
width: containerWidth
} = containerPos;
// bullet 表示候选弹幕对象(下同)
const { width, right } = bullet.getBoundingClientRect();
// 计算弹幕移动速度
const moveV = (containerWidth + width) / duration;
// 弹幕需要移动的距离
const leftDistance = right - containerLeft;
// 弹幕剩余跑完时间(单位s)
const leftDuration = leftDistance / moveV;
// 弹幕参照容器定位
bullet.style.position = 'absolute';
// 弹幕出生点位(水平偏移量)
bullet.style.left = `${containerWidth}px`;
// pos是当前弹幕元素被分配到的轨道编号,channelHeight表示轨道高度
bullet.style.top = `${pos * channelHeight}px`;
// 弹幕水平向左运动
bullet.style.transition = `transform ${leftDuration}s linear 0s`;
bullet.style.transform = `translateX(-${right - containerLeft}px)`;
通过以上分析可知,由于容器和弹幕的宽高属性是确定的,涉及弹幕的水平方向出生点位和速度也就被间接确定了。
这就意味着:一旦弹幕挂载到轨道中,即可进入预置的运动状态。
调度策略
设想,如果存在一个调度策略,能够智能地将弹幕分配至合适的轨道,在弹幕挂载阶段已经完成对弹幕重叠的检测——那么就无需引入复杂的物理引擎或者是实时碰撞计算了,awesome~
那么,如何设计这个调度策略呢?
回到典型的弹幕场景:同一轨道中所有弹幕元素从右向左&同向匀速运动;在展示期间,轨道中所有弹幕元素均不发生重叠。
不难想到,「轨道中所有弹幕元素均不发生重叠」的问题可以归约为:「如何避免轨道中前后两个相邻弹幕弹幕元素之间的重叠」。
试想,轨道中前后弹幕元素之间互不重叠,那么整条轨道所有弹幕也就确保彼此不会重叠了。例如上图中,弹幕君-2与弹幕君-1、弹幕君-3与弹幕君-2在移动期间互不重叠;对于即将进入轨道的弹幕君-4,只需要保证其与弹幕君-3保持移动期间不重叠,则整个系统便可满足互不重叠的状态。
于是我们成功地将一系列弹幕间的运动关系,降维到了相邻弹幕元素的两两关系。
追及问题
判断两个弹幕在水平方向上是否发生重叠,实质就是就是对追及问题进行讨论了。
基于红领巾时代掌握的知识,我们知道:对于两个对象的匀速直线运动,通过公式路程差 / 速度差 = 追及时间
来判断对象是否会相遇(追及时间是否大于0
)。
这里的「相遇」,与弹幕场景中「重叠」的概念不谋而合。
实际操作中,对于一个寻求某条合适轨道的弹幕元素,只需要将其与轨道中最后加入的弹幕元素(即轨道中最右侧的弹幕元素)进行比较,通过两者的追及问题计算,便可判断该轨道是否满足当前候选弹幕的插入条件。
代码简单示意如下:
checkChannel() {
// containerWidth、containerLeft等,表示容器相关属性
// bullet:表示候选弹幕元素对象
// channel:表示轨道,存储进入轨道的弹幕对象
const lastPos = channel.length - 1;
const lastBullet = channel[lastPos];
if (lastBullet) {
const lastBulletPos = lastBullet.getBoundingClientRect();
// 轨道中最后一个元素要求已经全部进入展示区域
if (lastBulletPos.right > containerRight) {
return false;
}
// 基本公式:s = v * t
const lastS = lastBulletPos.left - containerLeft + lastBulletPos.width;
const lastV = (containerWidth + lastBulletPos.width) / lastBullet.duration;
const lastT = lastS / lastV;
const newS = containerWidth;
const newV = (containerWidth + bullet.width) / bullet.duration;
const newT = newS / newV;
// 追及问题
if (lastV < newV && lastT > newT) {
return false;
}
}
return true;
}
经过上面checkChannel()
的计算,如果返回true
则表示当前轨道可以接受候选弹幕的挂载。
最终弹幕元素是否能够挂载到某条轨道上,还取决于其他因素:若弹幕本身高度较高&需要跨越多个轨道,那么弹幕需要对相关干涉到的轨道进行巡检后,才能确定是否使用该轨道作为自己的挂载目标。
综上,调度策略可简要描述如下:
周期性巡检轨道并尝试插入候选弹幕元素,检查候选元素与轨道之间是否满足挂载条件,包括:
- 溢出检查:候选弹幕元素高度不超过所有轨道之和;
- 重复性检查:候选弹幕元素尚未出现在轨道中;
- 重叠检查:候选弹幕元素,与所巡检的轨道及其下方被占用的轨道(如果弹幕高度较高)中最右侧的弹幕元素不发生重叠
满足以上全部条件的轨道方可作为候选弹幕挂载。
小结
通过对平面中竖直和水平方向的分析,我们将宽泛的弹幕重叠问题,收敛为轨道中相邻弹幕两两之间的追及问题,最终获得了将候选弹幕挂载到合适轨道中的调度策略。
在以上讨论中,我们侧重呈现一个问题分解的过程,并未过多地深入到代码实现的细节,希望能给到大家一些有益的启发哈。
原文链接:https://www.zhihu.com/question/370464345/answer/1021530502