今年春晚的弹幕,有点不一样

想必有很多热心网友发现了,今年春晚第一次搞直播弹幕,还跟B站联动。原本以为就是图个热闹,结果看着看着发现有点意思——弹幕会根据镜头的位置带一些倾斜角以更好的带来视觉立体感,主持人说话的时候,弹幕会主动让开面部;重要时刻飘过的金色弹幕,还自带光影效果。
在这里插入图片描述
作为一名技术人, 我去搜索了一下相关数据,B站作为马年总台春晚的独家弹幕视频平台,2月16日春晚直播期间,B站弹幕总数达1.33亿,平均每秒发送7700条。

得,这下彻底没法专心看节目了。

弹幕怎么“活”起来的

先说说最基础的。一条弹幕从生成到消失,其实就是一个数据对象的一生。

export class BulletComment {
  public id: number;                    // 唯一标识
  public content: Resource | string;    // 弹幕内容
  public color: string;                 // 弹幕颜色
  public positionY: number;             // Y 轴位置(百分比)
  public translateX: number;            // X 轴位置(vp)
  public speed: number;                 // 移动速度
  public isUserBulletComment: boolean;  // 是否用户发送

  constructor(content: Resource | string, isUserBulletComment: boolean = false) {
    this.id = new Date().getTime();
    this.content = content;
    this.color = '#FFFFFF';
    
    // 随机 Y 轴位置(4 个轨道)
    let randomIndex = Math.floor(Math.random() * 5);
    this.positionY = (randomIndex + 1) % 4 * 33;
    
    // 从右侧屏幕外开始
    this.translateX = 1000;
    
    // 移动速度
    this.speed = 2;
    
    this.isUserBulletComment = isUserBulletComment;
  }
}

每条弹幕都有编号、内容、颜色、在屏幕上的垂直位置、水平位置、移动速度。这就像给每个观众发了一张“入场券”,告诉它什么时候从右边进场,走哪条跑道,跑多快。

春晚那天晚上,无数这样的数据对象在系统里生成、移动、消失。我们看到的满屏弹幕,其实就是这些对象在循环刷新。

如何实现流畅的弹幕效果

在这里插入图片描述

看直播的时候,弹幕飘得特别顺滑,没有一卡一卡的感觉。这是因为需要实时刷新每条弹幕的位置。

private startAnimation() {
  if (this.timerId > 0) {
    clearInterval(this.timerId);
  }
  
  // 每 16ms 更新一次(约 60fps)
  this.timerId = setInterval(() => {
    let needUpdate = false;
    
    // 更新所有弹幕位置
    this.bulletComments.forEach(item => {
      const positionX = item.translateX - item.speed;
      if (positionX !== item.translateX) {
        item.translateX = positionX;
        needUpdate = true;
      }
    });
    
    // 移除超出屏幕的弹幕
    const beforeLength = this.bulletComments.length;
    this.bulletComments = this.bulletComments.filter(item => item.translateX > -20);
    
    // 触发 UI 刷新
    if (needUpdate || this.bulletComments.length !== beforeLength) {
      this.forceUpdate = !this.forceUpdate;
    }
  }, 16);
}

这段代码干的事很简单:每16毫秒,把每条弹幕往左挪一点,挪到屏幕外面的就扔掉。人眼看连续的画面需要每秒24帧以上,60帧已经远远超出人眼的感知极限,所以会觉得特别流畅。

不过这里有个细节:弹幕太多会互相遮挡,也影响性能。所以最好控制同屏弹幕数量,超过50条就从最老的开始扔掉。春晚也是这么干的——你仔细看,屏幕上永远不会同时出现太多弹幕。

用户弹幕怎么区分

看春晚的时候,我发现有些弹幕的样式不一样。往往在实际项目里,我们可以通过不同的组件样式,去区分用户的类型,比如是是否是会员用户、用户渠道来源等等不同的业务效果,比如代码可以这么写:

if (item.isUserBulletComment) {
  // 用户弹幕样式(带边框)
  Text(item.content)
    .fontSize(14)
    .fontColor(item.color)
    .translate({ x: `${item.translateX}vp`, y: 0 })
    .position({ y: `${item.positionY}%` })
    .backgroundColor('rgba(0,0,0,0.4)')
    .borderRadius(999)
    .borderWidth(1)
    .borderColor('rgba(255,255,255,0.6)')
    .padding({ top: 4, left: 8, bottom: 4, right: 8 })
    .opacity(0.5)
} else {
  // 其他弹幕样式
  Text(item.content)
    .fontSize(14)
    .fontColor(item.color)
    .translate({ x: `${item.translateX}vp`, y: 0 })
    .position({ y: `${item.positionY}%` })
    .backgroundColor('rgba(0,0,0,0.4)')
    .borderRadius(999)
    .padding({ top: 4, left: 8, bottom: 4, right: 8 })
    .opacity(0.5)
}

一个简单的边框参数,就让两种弹幕有了身份区别。用户发弹幕的时候,会在数据模型里把isUserBulletComment设为true,渲染的时候就会自动加上白色边框。

蒙版弹幕:B站是怎么让弹幕学会“躲人”的

在这里插入图片描述

这次春晚最让人好奇的是弹幕会“躲人”,实际上之前在刷b站的视频时看到这个功能,我个人就比较感兴趣。后来翻到一篇B站的技术分享,才发现这功能背后藏着不少门道。

B站面对的问题其实很直接:弹幕太多会把画面里的人物挡住,影响观看体验。但要是简单地把弹幕都挪到边上去,又少了那种“满屏都是网友”的热闹劲儿。最好是弹幕照飘,但人物不挡。
在这里插入图片描述

这个需求听起来简单,实现起来却有两个绕不开的坎。

第一个坎是成本。B站每天要处理海量的视频内容,如果都在服务器端做人像识别,服务器开销太大了。他们需要的是能在用户设备上跑的方案。

第二个坎是性能。设备端跑机器学习模型,搞不好就把CPU占满了,视频卡顿不说,笔记本风扇能转得飞起。用户肯定不答应。

B站最后用MediaPipe的人体分割模型解决了第一个坎。这个模型能在浏览器里实时提取视频中的人像轮廓,生成一个遮罩——人像区域透明,背景区域白色。然后把这个遮罩通过CSS的mask-image属性应用到弹幕层上,弹幕飘到人像区域就会自动被“挖空”。

原理听起来不复杂,但真正难的是第二个坎——性能。
在这里插入图片描述

从70%到5%:一场性能优化的接力赛

B站的技术团队一开始把方案跑起来,发现CPU占用直奔70%。这个数字意味着什么?一些性能不好的笔记本,看个视频风扇狂转。必须优化。

他们做的第一件事是降低遮罩的刷新频率。视频一般是30帧/秒,但遮罩没必要刷那么快,人眼对弹幕避让的敏感度没那么高。降到15帧/秒,体验上基本看不出差别,CPU直接降到50%。

第二件事是把耗时的操作挪到后台。原来的实现里,每次生成遮罩都要在当前线程跑一遍完整的流程,其中canvas操作和图片导出特别占资源。他们改用OffscreenCanvas加Web Worker,把这些活儿扔到后台线程去干,主线程只负责展示结果。这一下CPU又降了一截,到33%左右。
在这里插入图片描述

第三件事最有意思——给遮罩“降分辨率”。仔细想想,弹幕避让人物需要的是精确轮廓,但不需要高清大图。他们把遮罩的尺寸缩小到360P甚至更低,导出的时候图片从100多KB变成十几KB,渲染压力小多了。这一步优化完,CPU降到了15%。
在这里插入图片描述

最后一步:画面里没人的时候,干脆不跑了。用getImageData判断画面里有没有人像区域,没有就直接停掉整个计算流程。这下CPU能降到接近0%。

一圈折腾下来,CPU占用从70%干到5%,体验丝滑,风扇不转,功能上线。B站后来统计,上线这个功能后,用户观看会话时长增加了30%。
在这里插入图片描述

虽然上面的代码没有在直接实现动态蒙版,但鸿蒙系统提供了mask接口,理论上可以接入图像识别算法,实时生成蒙版。
或者也可以参考我们系列文章之前的内容,利用MindSpore Lite Kit去转换类似的图像识别算法,参考b站的思路进行优化。

字幕系统:多层级之间的处理

在这里插入图片描述

弹幕热闹,字幕也不能落下。我们发现字幕层级有一些细节,它的层级在人像之上,但却可以被其他的弹幕遮挡,我们先实现一个基础的字幕系统。

@Component
export struct CaptionFontView {
  @Prop captionFont: CaptionFont;
  close: (captionFont: CaptionFont | null) => void = () => {};

  // 字体家族选择
  @Builder
  FontFamily(text: ResourceStr) {
    Button(text)
      .fontSize(16)
      .fontColor(this.captionFont.family === text ? Color.Black : Color.White)
      .backgroundColor(
        this.captionFont.family === text ? 
        'rgba(255,255,255,0.9)' : 
        'rgba(255,255,255,0.1)'
      )
      .onClick(() => {
        this.captionFont.family = text;
      })
  }

  // 字体大小选择
  @Builder
  FontSize(text: ResourceStr) {
    Button(text)
      .fontSize(16)
      .fontColor(this.captionFont.size === Number(text) ? Color.Black : Color.White)
      .backgroundColor(
        this.captionFont.size === Number(text) ? 
        'rgba(255,255,255,0.9)' : 
        'rgba(255,255,255,0.1)'
      )
      .onClick(() => {
        this.captionFont.size = Number(text);
      })
  }

  // 字体颜色选择
  @Builder
  FontColor(captionFontColor: ResourceColor) {
    Column() {
      // 选中标记
      if (this.captionFont.color === captionFontColor) {
        Column()
          .width(12)
          .height(12)
          .borderRadius('50%')
          .border({ width: 1, color: Color.Gray })
      }
    }
    .width(24)
    .height(24)
    .borderRadius('50%')
    .backgroundColor(captionFontColor)
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.captionFont.color = captionFontColor;
    })
  }

  build() {
    Column() {
      // ... 布局代码
    }
  }
}

这个组件是一个独立的设置面板,可以选择字体、大小、颜色。用户改设置的时候,字幕会实时变化,所见即所得。

字幕文件的格式也很标准,就是最常见的SRT:

1
00:00:00,000 --> 00:00:03,000
这是第一条字幕

2
00:00:03,000 --> 00:00:06,000
这是第二条字幕

系统通过AVPlayer加载字幕文件,然后监听字幕更新事件:

subtitleUpdateFunction(): void {
  if (this.avPlayer) {
    this.avPlayer.on('subtitleUpdate', (info: media.SubtitleInfo) => {
      if (info) {
        let text = info.text || '';
        let startTime = info.startTime || 0;
        let duration = info.duration || 0;
        
        // 更新当前字幕内容
        this.currentCaption = text;
      } else {
        this.currentCaption = '';
      }
    });
  }
}

播放器走到哪个时间点,就显示对应的字幕内容。简单可靠。

发弹幕这事儿,也有讲究

在这里插入图片描述

发弹幕最怕什么?打字打到一半,节目演完了。所以好的弹幕输入体验,应该能自动暂停视频,发完再继续。

// 横屏模式下的弹幕输入框
TextInput({ 
  text: this.bulletCommentInput, 
  placeholder: $r('app.string.placeholder') 
})
  .backgroundColor('rgba(255,255,255,0.4)')
  .placeholderColor('rgba(255,255,255,0.7)')
  .fontColor(Color.White)
  .height(30)
  .layoutWeight(1)
  .onFocus(() => {
    // 输入时暂停视频
    this.pausePlay();
    this.isInputtingBulletComment = true;
  })
  .onBlur(() => {
    // 失焦时恢复播放
    this.resumePlayback();
    this.isInputtingBulletComment = false;
  })
  .onChange((value: string) => {
    this.bulletCommentInput = value;
  })

// 发送按钮
Button() {
  Image($r('app.media.send_bulletcomment'))
    .width(24)
    .height(24)
}
.onClick(() => {
  // 关闭键盘
  try {
    inputMethod.getController().stopInputSession();
  } catch (exception) {
    hilog.error(0x0000, TAG, `stopInputSession failed: ${exception.message}`);
  }
  
  this.sendBulletComment();
})

点击输入框,视频暂停;点击发送,键盘收起,视频继续。这套逻辑虽然简单,但很贴心——不会让你错过任何精彩瞬间。

一些没写进代码的观察

翻完这些代码和B站的技术文章,再看今年春晚的弹幕,感觉不太一样了。

那些看似随意飘过的文字,背后是一套完整的算法在运转:数据模型、动画循环、性能优化、交互设计,每个环节都得考虑到位。

  • 弹幕轨道为什么要分成四层?因为实验证明四层最舒服,太少显得冷清,太多互相遮挡。

  • 为什么用户弹幕要加白边?因为要让观众感觉到“这是我发的”,有参与感。

  • 为什么要限制同屏弹幕数量?因为要保证每条弹幕都能被看清楚,而不是一锅粥。

B站那套从70%到5%的优化路径也让我想起一件事:很多时候,技术方案看起来很美,但要真正落地,得靠这种一点点抠细节的功夫。而他们最终换来的是用户会话时长增加30%——用户不会知道你用了什么技术,他们只知道“在这儿看视频很舒服”。

这些细节,看节目的时候根本注意不到,但缺了任何一个,体验都会打折扣。

我突然有点期待明年的春晚在技术上,又会玩出什么新花样。

参考资料

哔哩哔哩官方弹幕技术文档总帖

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐