弹幕组件深度解读

2,105 阅读8分钟

弹幕组件分为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…

呈现效果

Untitled.png

通过 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…

实现效果如下:

Untitled 1.png

webGL型

webgl实际上用了canvas的一个3d渲染上下文,在绘制平面内容时,和canvas 2d相比,webgl更为直接的利用了gpu硬件,在某些场合,几乎可以摆脱cpu的限制,达到性能极致。

webGL实现的弹幕组件有着更高的自由度,能实现一些特效,例如:

Untitled.gif

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的性能对比

让我们用实际的例子来测试下三者的性能区别:渲染大量的精灵粒子。

  1. dom

wow.techbrood.com/uploads/210…

  1. canvas (2d)

wow.techbrood.com/uploads/210…

  1. webgl

wow.techbrood.com/uploads/210…

在i7独显笔记本上,当粒子数量超过5000的时候,

dom的fps掉到10几了。

canvas勉强还有20+,

而webgl仍然保留在30+。

由此可以看出当弹幕较少时可以使用dom型或canvas型,当弹幕较多时适合使用webgl型。