作者:周恩杰
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个要点:
-
胶水代码中不可用
await/async
:部分 JavaScript 运行环境无法使用胶水代码中的异步方法,因此需要将其改成同步的方式:// debug.js的胶水代码 const { exports } = await WebAssembly.instantiate(module, adaptedImports); // 字节跨端框架环境引用时改为 const wasmInstance = new WebAssembly.Instance(module, adaptedImports); const exports = wasmInstance.exports;
-
修改
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; }
-
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);
}
}
export function getWrapperdArray(arg: _F32ArrayWrapper): Float32Array {
return arg.arr;
}
借助这种方法,我们得以在 JavaScript 和 WASM 之间始终使用同一个 buffer 来读写粒子数据。在每一帧 CPU 计算结束后,把这个 buffer 作为缓冲数组提交给 GPU 来作为渲染的顶点数据。
4.4 性能表现
最后来看一下经过 JavaScript 优化的性能数据,我们将 JavaScript 和 WASM 的计算耗时统计起来进行对比。
图 6. iPhone 机型 WASM 与非 WASM 的性能对比
在不同的机型上,使用不同的 JavaScript 引擎,在具体性能表现上会有一些区别。但整体上看,WebAssembly 带来的性能优化还是非常可观,如上图所示提升了 2~10 倍的计算速度。
5. 总结
到此为止,我们已经了解了如何在 JavaScript 项目中利用 WebAssembly 进行性能优化。将部分代码块转换为 AssemblyScript 后可编译出 WebAssembly 产物,该产物提供的接口能在任何一个 JavaScript 项目中调用,在不同的 JavaScript 引擎中可带来2~10倍的计算速度优化。
同时,该方案目前仍有一些存在的问题,如无法直接将 JavaScript/TypeScript 代码编译成 WebAssembly,无法处理项目依赖的 npm 包等等,这对我们开发效率会造成一些影响。但是当我们的代码性能瓶颈在 CPU 侧时,使用 WebAssembly 进行优化仍然是一个非常不错的选择。
6. 参考文献
[1]. The AssemblyScript Book: www.assemblyscript.org/introductio…
扫码关注公众号 👆 追更不迷路