WebAssembly 在抖音烟花特效中的应用
作者:周恩杰
点击此处查阅《走进 WebAssembly 的世界》系列文章完整版。
1. 前言
在本文中,我们将基于一个实际的客户端生产环境中遇到的问题,来观察如何使用 WebAssembly 技术打破原有的性能瓶颈,提升用户价值。在这个过程中,我们将引入 AssemblyScript 技术栈,把原有的 TypeScript/JavaScript 逻辑下沉 WebAssembly 中,从而实现性能的大幅提升,并通过实验验证具体的性能收益。
2. 背景
在我们看直播时,经常可以看见由用户送礼触发的炫酷礼物特效,比如抖音一号、嘉年华等等。在早期直播的礼物特效多以播放 MP4 为主,为此我们也自研了 AlphaPlayer 的方案。客户端通过 AlphaPlayer 进行播放,设计侧只需要生产 MP4 资源即可,基本上属于业界比较常规的特效播放方案,比较适合高频迭代的常规礼物。与此同时,该方案的瓶颈也很明显:难以支持交互类特效、受资源包体积约束无法做到大量排列组合的随机性特效。
由于 MP4 无法实现具有定制化,随机性与交互性强的礼物特效,直播营收侧引入了一种新的特效方案:基于 WebGL 的跨端渲染方案 (用 Web 技术栈快速构建 Native 视图的高性能跨端框架,下文中用"字节跨端框架"代替) 。万象烟花就是由该方案实现的礼物特效之一。
在字节跨端框架的环境中,该特效使用公司自研的基于 JavaScript 的渲染引擎 "Sar Engine"。由于该引擎在需求开发阶段尚未具备通用的 GPU 粒子系统。因此,烟花粒子系统的属性都使用 CPU 计算更新,需要在 JavaScript 层处理粒子的位移、大小、颜色等,从而带来了不小的性能负担。
相对于 C++ 实现的渲染引擎,JavaScript 的低运行效率会严重影响渲染效果。通过抓帧和性能压测分析,如图 2 所示,可以观测到性能的瓶颈在 CPU 上。因此,需要采用一些手段进行优化以提高性能。在烟花特效中将部分重复且重 CPU 计算的逻辑转换为 WebAssembly 进行调用便是其中的重要优化之一。
3. AssemblyScript 在字节跨端框架中的应用
在上文中提到使用 WebAssembly 对字节跨端框架环境中的 JavaScript 代码进行优化,那么我们首先要做的便是探索如何将已有的 JavaScript 代码编译成 WebAssembly 产物,可以在该环境中加载并运行。AssemblyScript[1]可以帮助我们快速将业务代码中的 TypeScript 代码转换成可编译成 WebAssembly 产物的格式,结合框架环境,其主要步骤如下
由上图可见,编译工具和业务代码是独立的,可以在本地编译好 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 两类:
对于使用者而言,需要了解以下三个产物的作用:
- 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 进行优化。整体思路可参考下图:
主要的 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);