WebAssembly实战——实现粒子系统

前言

之前为大家讲解了一些WebAssembly相关的概念和核心原理,今天我们就使用WebAssmebly完成一个实际小项目,废话不多说,撸起袖子直接开干!

编译器选型

支持WebAssembly的语言很多,有C,C++,Rust......,不过今天我向大家安利一个名为AssemblyScript的语言,它采用了类TypeScript的语法设计,对于前端小伙伴来说十分的友好!几乎可以无痛的将原有的ts代码迁移过去。

如果你要问我AssemblyScript这个语言跟汇编语言(Assembly)有什么关系的话,我只能告诉你没有关系,毕竟官方也吐槽了。AssemblyScript 与 Assembly的关系就像是JavaScript与Java的关系一样!哈哈哈,真是官方吐槽最为致命啊!

AssemblyScript介绍

AssemblyScript十分的易用,它有着类似于TypeScript的语法,安装它也十分的简单,与安装其他npm包类似,只需要执行 npm install就好了!

我们先来小试牛刀吧!

创建项目

首先是创建项目,我们使用官方提供的方法来创建一个AssemblyScript的项目。

Getting started | The AssemblyScript Book

在本文中我就不具体展开了。

index.ts使用 AssemblyScript编写一个计算斐波那契数列的函数。

css 复制代码
 export function fib(n: i32): i32 {
   var a = 0, b = 1
   if (n > 0) {
     while (--n) {
       let t = a + b
       a = b
       b = t
     }
     return b
   }
   return a
 }

最后在命令行中执行命令:

arduino 复制代码
 npm run asbuild:debug

注意,asbuild:debugasbuild:release在运行时的性能上有较大的差异,在开发调试时我们可以使用 debug版本的编译结果,但是在生产环境中千万别忘了一定要使用 release版本的编译结果!

编译结束后,我们能够得到 debug.wasmdebug.js 两个编译产物,我们直接在index.html中引入 debug.js即可。为什么不直接引入 debug.wasm?因为 debug.js 中包含了一些胶水代码,它包括但不限于以下的功能:

  1. 加载 WASM文件
  2. 类型转换
  3. 导入导出WASM,JavaScript的对象等
xml 复制代码
 <!DOCTYPE html>
 <html lang="en">
     <head>
         <meta charset="UTF-8" />
         <meta name="viewport" content="width=device-width, initial-scale=1.0" />
         <title>Fib</title>
     </head>
     <body></body>
     <script type="module">
         import * as module from './build/debug.js';
         console.log(module);
     </script>
 </html>

我们可以在控制台中打印一下 module 对象,看看其中到底有什么内容。

我们可以从上图中看到,导出的对象中除了我们编写 fib函数以外,还有一个 memory 对象。在之前的深入浅出WebAssembly (一)------ 理解WAT - 掘金 (juejin.cn)文章中我们说过,memory 对象是一段JavaScript与 WebAssembly共同使用的一段线性内存空间。由于我们的模块中并不涉及使用这段内存,所以它的大小为0。

接下来我们可以直接执行module.fib(10)来查看运行结果。最终的运行结果为

scss 复制代码
 fib(10) = 55

登堂入室

接下来,我们要开始编写一个稍微复杂的示例,我们编写一个粒子程序,创建N个粒子,让其无序的移动。

为了说明一些问题,我们采用一个比较特殊的实现方式。我们使用一个数组来保存粒子的位置,使用另一个数组来保存粒子的速度。

在TypeScript中的函数签名如下:

typescript 复制代码
 export function simulate(
     posArray: number[],
     velocityArray: number[],
     dt: number
 ): void;
 ​

我们在 AssmeblyScript可以将这段代码直接搬过来,我们直接实现即可。实现如下。

ini 复制代码
 function clamp(x: number, min: number, max: number): number {
     if (x < min) {
         x = min;
     } else if (x > max) {
         x = max;
     }
     return x;
 }
 ​
 export function simulate(
     posArray: number[],
     velocityArray: number[],
     width: number,
     height: number,
     dt: number
 ): void {
     for (let i = 0; i < posArray.length; i += 2) {
         let x = posArray[i];
         let y = posArray[i + 1];
 ​
         let vx = velocityArray[i];
         let vy = velocityArray[i + 1];
 ​
         x = x + vx * dt;
         y = y + vy * dt;
 ​
         if (x < 0 || x > width) {
             x = clamp(x, 0, width);
               vx = -vx;
         }
 ​
         if (y < 0 || y > height) {
             y = clamp(y, 0, height);
             vy = -vy;
         }
 ​
         posArray[i] = x;
         posArray[i + 1] = y;
 ​
         velocityArray[i] = vx;
         velocityArray[i + 1] = vy;
     }
 }
 ​

输入 npm run asbuild:debug编译~启动!

编译完成后,我们可以测试一下 simulate 函数耗时为多少,这里我采用了1000个粒子进行测试。

ini 复制代码
 ​
 import * as module from './build/debug.js';
 ​
 const size = 1000;
 const width = 500;
 const height = 500;
 ​
 const posArray = new Array(size * 2).fill(0);
 const velArray = new Array(size * 2).fill(0);
 for (let i = 0; i < posArray.length; i += 2) {
     posArray[i] = Math.random() * width;
     posArray[i + 1] = Math.random() * height;
 ​
     velArray[i] = Math.random() * 200;
     velArray[i + 1] = Math.random() * 200;
 }
 console.time('simulate');
 module.simulate(posArray, velArray, 0.016);
 console.timeEnd('simulate');

测试结果如下:

但是,但是!!!我用JS同样实现了我们的粒子系统的逻辑,发现同样的逻辑JS中仅需要 0.03ms!

为什么慢了这么多!!!

我发现这个叫 _lowerArray的函数就占据了 )0.96ms的时间!

这是为什么呢?

噢~!因为我们在JS代码中直接使用了 Array对象,而 Array对象是不能够直接被WebAssembly的代码所使用的。记住:

在WebAssembly当中,有且仅有4中基本的数据类型:i32, i64, f32, f64 。即32位整型,64位整型,32位浮点数,64位浮点数,总共就四类数据类型(SIMD数据类型不计算在其中,本文不做介绍

所以我们要尽可能的避免数据的复制。那么解决办法是什么呢?答案是显而易见的,我们需要直接将数据放入到JavaScript与WebAssembly共用的那段线性内存当中。这段内存就是之前看到的memory对象。

那么,我们AssemblyScript中的代码需要修改,首先我们将函数签名改为如下的代码:

arduino 复制代码
 export function simulateSystem(
     posPtr: usize,
     velPtr: usize,
     size: i32,
     width: f64,
     height: f64,
     dt: f64
 ): void;

posPtrvelPtr表示的是"指针",它表示的是在共享内存中的偏移位置。我们可以使用 AssemblyScript提供的load这个API来加载共享内存中的数据。相对的,使用storeAPI将数据存回共享内存中。完整的代码如下:

ini 复制代码
 export function simulateSystem(
     posPtr: usize,
     velPtr: usize,
     size: i32,
     width: f64,
     height: f64,
     dt: f64
 ): void {
     let i: i32;
     let x: f64, y: f64, vx: f64, vy: f64;
     for (i = 0; i < size * 2; i += 2) {
         x = load<f64>(posPtr + i * 8);
         y = load<f64>(posPtr + (i + 1) * 8);
 ​
         vx = load<f64>(velPtr + i * 8);
         vy = load<f64>(velPtr + (i + 1) * 8);
 ​
         x = x + vx * dt;
         y = y + vy * dt;
 ​
         if (x < 0 || x > width) {
             x = clamp(x, 0, width);
             vx = -vx;
             store<f64>(velPtr + i * 8, vx);
         }
         if (y < 0 || y > height) {
             y = clamp(y, 0, height);
             vy = -vy;
 ​
             store<f64>(velPtr + (i + 1) * 8, vy);
         }
 ​
         store<f64>(posPtr + i * 8, x);
         store<f64>(posPtr + (i + 1) * 8, y);
     }
 }

注意,在使用 loadstore这两个API时,需要显式地制定读写数据的数据类型。还有你也许可能会对循环中的自变量i为什么要乘以8感到疑惑。这是因为我们的位置数据是用双精度浮点数进行表示的。它对应的正是 f64类型的数据,它占据64位、8个字节,所以两个相邻的数据之间的距离是8个字节,所以需要乘以8。

另外,我们在开辟内存空间时,最好采用AssemblyScript提供的 heap.allocAPI。这样可以避免我们自己计算内存偏移量。所以我们导出一个名为 malloc的函数。

arduino 复制代码
 export function malloc(size: usize): usize {
     return heap.alloc(size);
 }

继续,编译~启动!我们先使用malloc函数来获取共享内存中的可用空间的起始偏移地址。

arduino 复制代码
 const size = 1000;
 const posPtr = module.malloc(size * 2 * 8);
 const velPtr = module.malloc(size * 2 * 8);

再使用下面的代码来填充随机数据

ini 复制代码
 const view = new Float64Array(module.memory.buffer);
 for (let i = 0; i < size * 2; i++) {
     const x = Math.random() * canvas.width;
     const y = Math.random() * canvas.height;
     const vx = Math.random() * 100;
     const vy = Math.random() * 200;
 ​
     view[posPtr / 8 + i] = x;
     view[posPtr / 8 + i + 1] = y;
 ​
     view[velPtr / 8 + i] = vx;
     view[velPtr / 8 + i + 1] = vy;
 }

再在每一帧中对粒子的位置进行迭代计算并且渲染~

arduino 复制代码
 const dt = 16;
 function tick() {
     module.simulateSystem(
         posPtr,
         velPtr,
         size,
         canvas.width,
         canvas.height,
         dt / 1000
     );
     
     render(ctx, view.subarray(posPtr / 8, posPtr / 8 + size * 2));
     setTimeout(tick, dt);
 }

我们也使用JS编写相同的逻辑,并进行性能比较(JS代码略)。最终的性能对比结果如下:

另外,再次强调,编译一定要使用release版本的产物,这样才可以让WebAssembly发挥出最大的威力!!!

通过上图我们可以看出来,避免数据重复复制是多么的重要呀!再加上使用了release版本的编译产物,WebAssembly程序的性能几乎是JavaScript程序的3倍~ 这已经是一个相当可观的性能提升了!

小结

今天我们使用AssemblyScript实现了一个简单的粒子系统。学习到了如何直接往共享内存中填充数据并通过数据在内存中的偏移量来使用它。

另外,我们在发布到生产环境时,一定要选择使用release版本的编译产物哦!这一点已经在文中反复强调过非常多遍了~!

相关推荐
胡西风_foxww14 分钟前
【ES6复习笔记】数值扩展(16)
前端·笔记·es6·扩展·数值
mosen86816 分钟前
uniapp中uni.scss如何引入页面内或生效
前端·uni-app·scss
白云~️17 分钟前
uniappX 移动端单行/多行文字隐藏显示省略号
开发语言·前端·javascript
沙尘暴炒饭19 分钟前
uniapp 前端解决精度丢失的问题 (后端返回分布式id)
前端·uni-app
昙鱼33 分钟前
springboot创建web项目
java·前端·spring boot·后端·spring·maven
天天进步201538 分钟前
Vue项目重构实践:如何构建可维护的企业级应用
前端·vue.js·重构
小华同学ai41 分钟前
vue-office:Star 4.2k,款支持多种Office文件预览的Vue组件库,一站式Office文件预览方案,真心不错
前端·javascript·vue.js·开源·github·office
APP 肖提莫43 分钟前
MyBatis-Plus分页拦截器,源码的重构(重构total总数的计算逻辑)
java·前端·算法
问道飞鱼1 小时前
【前端知识】强大的js动画组件anime.js
开发语言·前端·javascript·anime.js
k09331 小时前
vue中proxy代理配置(测试一)
前端·javascript·vue.js