添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

WebAssembly 在抖音烟花特效中的应用

作者:周恩杰
点击此处查阅《走进 WebAssembly 的世界》系列文章完整版。

1. 前言

在本文中,我们将基于一个实际的客户端生产环境中遇到的问题,来观察如何使用 WebAssembly 技术打破原有的性能瓶颈,提升用户价值。在这个过程中,我们将引入 AssemblyScript 技术栈,把原有的 TypeScript/JavaScript 逻辑下沉 WebAssembly 中,从而实现性能的大幅提升,并通过实验验证具体的性能收益。

2. 背景

在我们看直播时,经常可以看见由用户送礼触发的炫酷礼物特效,比如抖音一号、嘉年华等等。在早期直播的礼物特效多以播放 MP4 为主,为此我们也自研了 AlphaPlayer 的方案。客户端通过 AlphaPlayer 进行播放,设计侧只需要生产 MP4 资源即可,基本上属于业界比较常规的特效播放方案,比较适合高频迭代的常规礼物。与此同时,该方案的瓶颈也很明显:难以支持交互类特效、受资源包体积约束无法做到大量排列组合的随机性特效。

由于 MP4 无法实现具有定制化,随机性与交互性强的礼物特效,直播营收侧引入了一种新的特效方案:基于 WebGL 的跨端渲染方案 (用 Web 技术栈快速构建 Native 视图的高性能跨端框架,下文中用"字节跨端框架"代替) 。万象烟花就是由该方案实现的礼物特效之一。


图 1. 万象烟花礼物特效

在字节跨端框架的环境中,该特效使用公司自研的基于 JavaScript 的渲染引擎 "Sar Engine"。由于该引擎在需求开发阶段尚未具备通用的 GPU 粒子系统。因此,烟花粒子系统的属性都使用 CPU 计算更新,需要在 JavaScript 层处理粒子的位移、大小、颜色等,从而带来了不小的性能负担。

相对于 C++ 实现的渲染引擎,JavaScript 的低运行效率会严重影响渲染效果。通过抓帧和性能压测分析,如图 2 所示,可以观测到性能的瓶颈在 CPU 上。因此,需要采用一些手段进行优化以提高性能。在烟花特效中将部分重复且重 CPU 计算的逻辑转换为 WebAssembly 进行调用便是其中的重要优化之一。

图 2. iPhone 7 JavaScript 版本烟花性能表现

3. AssemblyScript 在字节跨端框架中的应用

在上文中提到使用 WebAssembly 对字节跨端框架环境中的 JavaScript 代码进行优化,那么我们首先要做的便是探索如何将已有的 JavaScript 代码编译成 WebAssembly 产物,可以在该环境中加载并运行。AssemblyScript[1]可以帮助我们快速将业务代码中的 TypeScript 代码转换成可编译成 WebAssembly 产物的格式,结合框架环境,其主要步骤如下

图 3. 字节跨端框架应用 AssemblyScript 优化的步骤

由上图可见,编译工具和业务代码是独立的,可以在本地编译好 WASM 产物,在任何地方使用。关于 AssemblyScript 的开发环境搭建,可以参考教程[1],推荐使用官方教程的方式编译。下文中会详细介绍 AssemblyScript 的整个使用过程。

3.1 安装依赖与构建

我们需要克隆 AssemblyScript 的仓库,在本地安装完依赖并运行,使本地具有将 AssemblyScript 编译成 .wasm 产物的能力, 步骤如下:

git clone https://github.com/AssemblyScript/assemblyscript.git
cd assemblyscript
npm install
npm link
npm run dev # 打包 dist,不然会找不到 asc

按上述步骤执行后就完成了整个安装,后面可以通过 asc 命令进行编译。但通常我们不使用 asc 手动编译单个文件,而是通过 asbuild 命令自动编译,并同步生成胶水代码。

3.2 初始化项目

完成依赖安装后,我们可以执行 npx asinit . 在本地初始化一个 AssemblyScirpt 项目。这个项目可以放在实际的业务工程中,也可以放在其他地方,因为我们最终需要的仅仅是由该项目产生的接口文件与 .wasm 产物。

执行命令后会生成一些项目初始文件,其中以下两个是比较重要的:

# 编写AssemblyScript的位置,我们在此export的接口,在编译后都会在 .wasm 产物中提供接口
./assembly/index.ts
# 用于编写测试 .wasm 代码的地方,我们可以在此处引入 .wasm 产物,然后使用 JavaScript 代码调用进行测试
./tests/index.js

3.3 编译

在我们写好 AssemblyScirpt 代码后,使用以下命令进行编译:

npm run asbuild

编译的产物放在项目 build 目录下。如下所示,产物分为 debug 和 release 两类:

图 4. AssemblyScript 编译产物

对于使用者而言,需要了解以下三个产物的作用:

  • release.d.ts : 接口文件,我们在 AssemblyScript 代码中 export 的接口,都会在这个文件中声明;
  • release.js : 胶水文件,内部包含加载 .wasm 文件的逻辑,在业务代码中,我们会直接引用该部分的 JavaScript 代码;
  • release.wasm : WebAssembly 二进制产物文件。

3.4 加载与使用

3.4.1 Node.js 环境使用

AssemblyScript 的工具在初始化项目时,会自动生成一个 tests/index.js (见 3.2) 文件用于测试 Node.js 环境下的 .wasm 产物。由于 AssemblyScript 语法的要求较为严格,且一些常见类型的使用方式和 TypeScript 也有些区别,因此开发前期可以先在 Node.js 环境跑通,再移植到字节跨端框架环境下。

使用以下命令,就可以运行测试代码:

node index.js

3.4.2 字节跨端框架环境使用

到这一步,我们已经完成了 AssemblyScript 部分代码的编写与测试,并且编译出 .js .wasm 这两个最终产物。在业务代码引入这两个文件时,还需要进行一些适配工作:

编译参数指定

在编译 AssemblyScript 时,需要带上一些参数,才能正常导出给 JavaScript 侧使用。其中 initialMemory 用于指定初始内存大小(使用 TypedArray 时会用到,单位为 64KB)。如果该数值太小,在创建大数组时会出现访问越界等问题。如果能够事先确定需要使用的内存空间,那么就可以直接指定该参数,避免出现问题。

// 在编译命令中指定 memory 相关参数.
"asbuild:debug": "asc assembly/index.ts --target debug --exportRuntime --initialMemory=100"
"asbuild:release": "asc assembly/index.ts --target release --exportRuntime --initialMemory=100"

胶水代码适配

由 AssemblyScript 生成的 .js 产物带有部分 ES 高版本才支持的特性,而字节跨端框架环境还暂时无法支持对高版本特性的使用,必须要进行改写才可以正常运行。主要包括以下 3 个要点:

  1. 胶水代码中不可用 await/async :部分 JavaScript 运行环境无法使用胶水代码中的异步方法,因此需要将其改成同步的方式:
    // debug.js的胶水代码
    const { exports } = await WebAssembly.instantiate(module, adaptedImports);

    // 字节跨端框架环境引用时改为
    const wasmInstance = new WebAssembly.Instance(module, adaptedImports);
    const exports = wasmInstance.exports;
  2. 修改 FinalizationRegistry :如果 AssemblyScript 中的自定义类带有构造函数,则生成的胶水代码会用到该类进行内存回收,这是 ES 高版本的特性,在不支持的环境中需要删除:
    const registry = new FinalizationRegistry(__release); // 删去该行
    class Internref extends Number {}
    function __liftInternref(pointer) {
    if (!pointer) return null;
    const sentinel = new Internref(__retain(pointer));
    registry.register(sentinel, pointer); // 删去该行
    return sentinel;
    }
  3. TypedArray 引用传递:如果想在 WebAssembly 和 JavaScript 之间传递 TypedArray 引用,需要在胶水代码中删掉对应的 slice() 调用,避免传递时产生复制,而导致不必要的性能损耗:
    function __liftTypedArray(constructor, pointer) {
    if (!pointer) return null;
    const memoryU32 = new Uint32Array(memory.buffer);
    return new constructor(
    memory.buffer,
    memoryU32[pointer + 4 >>> 2],
    memoryU32[pointer + 8 >>> 2] / constructor.BYTES_PER_ELEMENT
    ).slice(); // 删去最后的 .slice(),避免数组深拷贝
    }

项目打包

我们会把编译出的 .wasm 产物放在 JavaScript 工程里作为二进制资源,如果部分项目不支持打包 .wasm 后缀的资源,可将其的后缀改为 .bin ,并且在 项目的配置文件里 (如 eden.config.js ) 的 module.exports 中添加如下代码,使该项目在打包页面时可以将 .bin 资源打包在内:

asset: {
    test: /.(bin|bmf|prefab|gltf|mp4|texture|geo|mat|model|patlas)$/

加载产物

运行时加载:现在我们的项目产物中已经有了 .wasm ,我们只需要将它作为一个二进制文件加载即可使用其导出的 JavaScript 接口,加载的代码放在 AssemblyScript 生成的 release/debug.js 里,按上述胶水代码适配中的步骤修改后,它便能正常运行了。也就是业务 JavaScript 代码可像调用普通 JavaScript 包接口一样调用 .wasm 文件的接口。

4. 优化 JavaScript 计算

经过上文介绍,我们了解到如何将通过 AssemblyScript 编译得到 WebAssembly 模块实现对原有的 JavaScript 逻辑进行优化。那么接下来,我们就以烟花的粒子系统为例进行一个实践,将在 CPU 侧执行、非常耗时的 JavaScript 计算打包进 .wasm 产物中,借助更高性能的 WebAssembly 来执行原来的计算逻辑,以达到优化的效果。

4.1 待优化代码

在烟花特效中,待优化程序是一段烟花粒子系统中的核心逻辑。我们在每帧对所有粒子进行一次属性更新,然后再将更新后的属性写入到 buffer 中,再提交给 GPU 进行渲染。由于粒子的数量成千上万,因此循环体中的内容在一帧的时间内(1 秒 30 帧,1 帧耗时 0.033 秒)执行很多次。这部分重复计算的代码,就可以使用 WebAssembly 进行优化。整体思路可参考下图:

图 5. 使用 WebAssembly 优化粒子系统

主要的 JavaScript 计算逻辑:

// 粒子数据更新
update() {
    this.clear();
    this.particles.items.forEach((particle: SimpleParticle) => {
      particle.age++;
      particle.alpha = 1 - (particle.age / particle.life);
      // particle.size = this.size * (1 - (particle.age / particle.life));
      particle.position.y += particle.dir.y;
      particle.position.x += particle.dir.x;
// 将粒子数据写入到VBO中
this.simpleEmitters.forEach(e => {
    if (e.isDispose) return;
    e.particles.items.forEach((p: SimpleParticle) => {
        buffer[offset++] = p.position.x;
        buffer[offset++] = p.position.y;
        buffer[offset++] = p.position.z;
        let color = p.color.getColor(1 - p.age / p.life);
        buffer[offset++] = color.r;
        buffer[offset++] = color.g;
        buffer[offset++] = color.b;
        buffer[offset++] = p.size;
        buffer[offset++] = p.alpha;
        buffer[offset++] = p.seed;

4.2 编写 AssemblyScript 代码

由于上述代码是内嵌在复杂的业务环境中的,带有众多上下文依赖,因此无法直接切换到 AssemblyScript 版本。我们需要手动将其抽出来,改写成可编译成 WebAssembly 的版本。主要的具体步骤如下:

  • 定义数据结构
    首先是定义好一些数据结构,便于和 TypeScript 的类进行数据交换。这里我们定义一些用于计算的二维三维向量,以及一个简单的粒子结构体和粒子的队列:
class _Vector2 {
 x: f32;
  y: f32;
class _Vector3 {
 x: f32;
  y: f32;
  z: f32;
class _Color {
 r: f32;
  g: f32;
  b: f32;
class _SimpleParticle {
 position: _Vector3;
  alpha: f32;
  size: f32;
  color: _BezierColor;
  life: f32;
  age: f32;
  dir: _Vector2;
class _QueueWrapper {
 particles: _SimpleParticle[];
  stk: _SimpleParticle[];
  top: i32;
  outerAlpha: f32;
  isDispose: boolean;
  maxCount: i32;
  count: i32;
  front: i32;
  end: i32;
  size: f32;
  color: _BezierColor;
  life: f32;
  • 定义接口
    在第一步的基础上,我们就可以编写两个接口,分别用来刷新粒子队列里的粒子属性,以及向一个 buffer 中写入队列中的粒子属性:
// 更新队列里的粒子属性
export function updateParticles(x: f32, y: f32, vx: f32, vy: f32, queue: _QueueWrapper): void {
  emitter(x, y, vx, vy, queue);
  clearDead(queue);
 for (let i = queue.front; i < queue.end; i++) {
 const p = queue.particles[i];
    p.age++;
    p.alpha = 1 - (p.age / p.life);
    p.size = queue.size * (1 - (p.age / p.life));
    p.position.y -= 0.2;
 if (p.age <= 5) {
      p.position.y += p.dir.y;
      p.position.x += p.dir.x;
// 在AssemblyScript中定义的buffer接口
class _F32ArrayWrapper {
 arr: Float32Array;
 constructor(num: i32) {
 this.arr = new Float32Array(num);
// 向buffer中写入队列中的粒子属性
export function syncEmitterGeometryAttributes(arg: _F32ArrayWrapper, offset: i32, queue: _QueueWrapper): i32 {
 let buffer = arg.arr;
 for (let i = queue.front; i < queue.end; i++) {
 let p = queue.particles[i];
    buffer[offset++] = p.position.x;
    buffer[offset++] = p.position.y;
    buffer[offset++] = p.position.z;
 let color = p.color.getColor(1 - p.age / p.life);
    buffer[offset++] = color.r;
    buffer[offset++] = color.g;
    buffer[offset++] = color.b;
    buffer[offset++] = p.size;
    buffer[offset++] = p.alpha * queue.outerAlpha;
 return offset;

实现完这些核心的计算接口后,我们将这个 AssemblyScript 文件编译成 .wasm 产物。然后按 3.4.2 步骤所述,将它放到业务工程中使用即可。

4.3 WebAssembly 接口调用

经过上面步骤,我们得到了提供计算功能的 WASM 模块,接下来就可以在业务的 JavaScript/TypeScript 代码里调用 WASM 模块暴露出来的接口,完成业务逻辑。值得注意的是, .wasm 文件在生成时,还会附带一个 .d.ts 文件,里面声明了这个二进制文件暴露的接口:

/**
 * assembly/index/syncEmitterGeometryAttributes
 * @param arg `assembly/index/_F32ArrayWrapper`
 * @param offset `i32`
 * @param queue `assembly/index/_QueueWrapper`
 * @returns `i32`
export declare function syncEmitterGeometryAttributes(arg: __Internref11, offset: number, queue: __Internref5): number;
 * assembly/index/updateParticles
 * @param x `f32`
 * @param y `f32`
 * @param vx `f32`
 * @param vy `f32`
 * @param queue `assembly/index/_QueueWrapper`
export declare function updateParticles(x: number, y: number, vx: number, vy: number, queue: __Internref5): void;

如上所示, syncEmitterGeometryAttributes updateParticles 就被声明为外部可使用的接口,在 TypeScript 代码里调用这两个方法即可:

updateParticles(
    this.curve.current.x + this.cluster.parameters.transX,
    this.curve.current.y + this.cluster.parameters.transY,
    this.curve.VX,
    this.curve.VY,
    this.queue
this.queues.forEach(q => {
    syncEmitterGeometryAttributes(this.wrapperdArray, offset, q);

值得一提的是,wasm 和 JavaScript 可以共享 buffer。比如,在上述代码中, syncEmitterGeometryAttributes 的第一个参数就是一个共享 buffer。

本例中,我们具体的做法是在 AssemblyScript 中构造一个包含 Float32Array 的类,然后在 JavaScript 侧通过调用 WASM 的接口获取到一个该类的对象,之后的计算和传参都使用该对象。最后,如果 JavaScript 侧需要用到该对象内部的 Float32Array,只需要在 AssemblyScript 定义一个接口即可。通过这种方式,可以完全避免 WASM 和 JavaScript 之间通过复制 buffer 进行数据交换带来的性能消耗。

class _F32ArrayWrapper {
    arr: Float32Array;
    constructor(num: i32) {
      this.arr = new Float32Array(num);