找回密码
 会员注册
查看: 13|回复: 0

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

[复制链接]

2万

主题

0

回帖

7万

积分

超级版主

积分
75127
发表于 2024-9-30 07:12:53 | 显示全部楼层 |阅读模式
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 产物的能力, 步骤如下:gitclonehttps://github.com/AssemblyScript/assemblyscript.gitcdassemblyscriptnpminstallnpmlinknpmrundev#打包dist,不然会找不到asc按上述步骤执行后就完成了整个安装,后面可以通过 asc 命令进行编译。但通常我们不使用 asc 手动编译单个文件,而是通过 asbuild 命令自动编译,并同步生成胶水代码。3.2 初始化项目完成依赖安装后,我们可以执行 npx asinit . 在本地初始化一个 AssemblyScirpt 项目。这个项目可以放在实际的业务工程中,也可以放在其他地方,因为我们最终需要的仅仅是由该项目产生的接口文件与 .wasm 产物。执行命令后会生成一些项目初始文件,其中以下两个是比较重要的:#编写AssemblyScript的位置,我们在此export的接口,在编译后都会在.wasm产物中提供接口./assembly/index.ts#用于编写测试.wasm代码的地方,我们可以在此处引入.wasm产物,然后使用JavaScript代码调用进行测试./tests/index.js3.3 编译在我们写好 AssemblyScirpt 代码后,使用以下命令进行编译:npmrunasbuild编译的产物放在项目 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 环境跑通,再移植到字节跨端框架环境下。使用以下命令,就可以运行测试代码:nodeindex.js3.4.2 字节跨端框架环境使用到这一步,我们已经完成了 AssemblyScript 部分代码的编写与测试,并且编译出 .js 与 .wasm 这两个最终产物。在业务代码引入这两个文件时,还需要进行一些适配工作:编译参数指定在编译 AssemblyScript 时,需要带上一些参数,才能正常导出给 JavaScript 侧使用。其中 initialMemory 用于指定初始内存大小(使用 TypedArray 时会用到,单位为 64KB)。如果该数值太小,在创建大数组时会出现访问越界等问题。如果能够事先确定需要使用的内存空间,那么就可以直接指定该参数,避免出现问题。//在编译命令中指定memory相关参数."asbuild:debug":"ascassembly/index.ts--targetdebug--exportRuntime--initialMemory=100""asbuild:release":"ascassembly/index.ts--targetrelease--exportRuntime--initialMemory=100"胶水代码适配由 AssemblyScript 生成的 .js 产物带有部分 ES 高版本才支持的特性,而字节跨端框架环境还暂时无法支持对高版本特性的使用,必须要进行改写才可以正常运行。主要包括以下 3 个要点:胶水代码中不可用 await/async:部分 JavaScript 运行环境无法使用胶水代码中的异步方法,因此需要将其改成同步的方式://debug.js的胶水代码const{exports}=awaitWebAssembly.instantiate(module,adaptedImports);//字节跨端框架环境引用时改为constwasmInstance=newWebAssembly.Instance(module,adaptedImports);constexports=wasmInstance.exports;修改 FinalizationRegistry:如果 AssemblyScript 中的自定义类带有构造函数,则生成的胶水代码会用到该类进行内存回收,这是 ES 高版本的特性,在不支持的环境中需要删除:constregistry=newFinalizationRegistry(__release);//删去该行classInternrefextendsNumber{}function__liftInternref(pointer){if(!pointer)returnnull;constsentinel=newInternref(__retain(pointer));registry.register(sentinel,pointer);//删去该行returnsentinel;}TypedArray 引用传递:如果想在 WebAssembly 和 JavaScript 之间传递 TypedArray 引用,需要在胶水代码中删掉对应的 slice() 调用,避免传递时产生复制,而导致不必要的性能损耗:function__liftTypedArray(constructor,pointer){if(!pointer)returnnull;constmemoryU32=newUint32Array(memory.buffer);returnnewconstructor(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;letcolor=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 中写入队列中的粒子属性://更新队列里的粒子属性exportfunctionupdateParticles(x:f32,y:f32,vx:f32,vy:f32,queue:_QueueWrapper):void{emitter(x,y,vx,vy,queue);clearDead(queue);for(leti=queue.front;i{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=newFloat32Array(num);}}exportfunctiongetWrapperdArray(arg:_F32ArrayWrapper):Float32Array{returnarg.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: https://www.assemblyscript.org/introduction.html#from-a-webassembly-perspective点击上方关注 · 我们下期再见点击左下方“阅读原文”,或扫描上方二维码,进入专栏阅读《走进 WebAssembly 的世界》完整版。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-15 20:50 , Processed in 0.512763 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表