利用 WebAssembly 在浏览器内实现光线追踪

发布于:2024-05-01 ⋅ 阅读:(23) ⋅ 点赞:(0)

最近在厂里小团队内做了一次关于 WebAssembly 这个不算新潮但也毫无热度的技术的科普分享,大概的讲了一下 asm.js 和 WebAssembly 的发展过程,并用著名《周末在家实现光纤追踪》中的代码为例,展示了一下 WebAssembly 相对优秀的性能表现(优秀当然是和 JavaScript 这个扶不起的语言比啦)。我自己呢之前也搞了个把 跑在 WebAssembly 的,可以很粗暴的对 CPU 性能做一个跑分,但都没有很详细的记录这个过程我都干了些什么,于是打算水一片记录一下 ,以防以后有用还要从头摸索

安装 Emscripten

Emscripten 是 WebAssembly 的工具链,我们需要手动安装。如果你的 macOS / Linux 中存在包管理器可以考虑优先用包管理器内提供的版本,如果不能满足需求可以可以查看来了解最新版本的详细安装方法。简单来说,我们只需要执行以下命令即可完成安装和激活:

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh

很显然这种安装方式只会在当前 shell 生效,如果有需求可以把 source ./emsdk_env.sh 放到当前 shell 的初始化脚本中,此处不再赘述。

获取并修改「Ray Tracing in One Weekend」的源码

这是一本比较有名的光线追踪入门科普读物(?),大家可以点击这个链接来阅读:,最终实现的代码大家也可以在本书的中找到,这次我们直接用教材的参考答案就行了,并不打算自己从头撸一个。

直接用原有的代码编译到 WebAssembly 当然没问题,但是实际上我打算做一些优化:

  1. 标准答案输出的图片方式是在 std::cout 输出 PPM,我们可以直接改成在 WASM 的内存中 allocate 一个 bitmap 并直接更新;
  2. 标准答案输出进度的方式是在 std::clog 输出 Scanline remaining xx,我们可以改成调用一个 JavaScript 函数,把进度输出到 JavaScript console 中,并把上面提到的 bitmap 绘制到 canvas 里,让大家伙可以直接看到进度。

我们先处理一下负责摄像机的 camera.h,这里我增加了一个 extern void report_number(number); 的定义,具体的实现后续会在 JavaScript 中补充,来实现上面优化中的第二点:

@@ -17,16 +17,18 @@
 #include "hittable.h"
 #include "material.h"
 
-#include <iostream>
-
+extern void report_number(int);
 
 class camera {
   public:
     double aspect_ratio      = 1.0;  // Ratio of image width over height
     int    image_width       = 100;  // Rendered image width in pixel count
+    int    image_height;             // Rendered image height
     int    samples_per_pixel = 10;   // Count of random samples for each pixel
     int    max_depth         = 10;   // Maximum number of ray bounces into scene
 
+    uint8_t *image_buffer = nullptr; // Bitmap for rendered image.
+
     double vfov     = 90;              // Vertical view angle (field of view)
     point3 lookfrom = point3(0,0,-1);  // Point camera is looking from
     point3 lookat   = point3(0,0,0);   // Point camera is looking at
@@ -37,26 +39,25 @@ class camera {
 
     void render(const hittable& world) {
         initialize();
-
-        std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
+        auto p = this->image_buffer;
 
         for (int j = 0; j < image_height; ++j) {
-            std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
+            if (j % 10 == 0)
+              report_number(image_height - j);
+            // std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
             for (int i = 0; i < image_width; ++i) {
                 color pixel_color(0,0,0);
                 for (int sample = 0; syyample < samples_per_pixel; ++sample) {
                     ray r = get_ray(i, j);
                     pixel_color += ray_color(r, max_depth, world);
                 }
-                write_color(std::cout, pixel_color, samples_per_pixel);
+                write_color(pixel_color, samples_per_pixel, p);
+                p += 4;
             }
         }
-
-        std::clog << "\rDone.                 \n";
     }
 
   private:
-    int    image_height;    // Rendered image height
     point3 center;          // Camera center
     point3 pixel00_loc;     // Location of pixel 0, 0
     vec3   pixel_delta_u;   // Offset to pixel to the right
@@ -68,6 +69,7 @@ class camera {
     void initialize() {
         image_height = static_cast<int>(image_width / aspect_ratio);
         image_height = (image_height < 1) ? 1 : image_height;
+        this->image_buffer = new uint8_t[image_height * image_width * 4];
 
         center = lookfrom;
 

以及负责颜色的 color.h

@@ -13,8 +13,6 @@
 
 #include "vec3.h"
 
-#include <iostream>
-
 using color = vec3;
 
 inline double linear_to_gamma(double linear_component)
@@ -22,7 +20,7 @@ inline double linear_to_gamma(double linear_component)
     return sqrt(linear_component);
 }
 
-void write_color(std::ostream &out, color pixel_color, int samples_per_pixel) {
+void write_color(color pixel_color, int samples_per_pixel, uint8_t *buffer) {
     auto r = pixel_color.x();
     auto g = pixel_color.y();
     auto b = pixel_color.z();
@@ -40,9 +38,11 @@ void write_color(std::ostream &out, color pixel_color, int samples_per_pixel) {
 
     // Write the translated [0,255] value of each color component.
     static const interval intensity(0.000, 0.999);
-    out << static_cast<int>(256 * intensity.clamp(r)) << ' '
-        << static_cast<int>(256 * intensity.clamp(g)) << ' '
-        << static_cast<int>(256 * intensity.clamp(b)) << '\n';
+
+    *(buffer + 0) = static_cast<int>(256 * intensity.clamp(r));
+    *(buffer + 1) = static_cast<int>(256 * intensity.clamp(g));
+    *(buffer + 2) = static_cast<int>(256 * intensity.clamp(b));
+    *(buffer + 3) = 255;
 }
 
 

最后我们再修改修改入口的 main.cc,增加几个函数用来给 JavaScript 获取一些画布的参数,以及给 main 函数改了个名:

@@ -9,6 +9,7 @@
 // along with this software. If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
 //==============================================================================================
 
+#include <emscripten.h>
 #include "rtweekend.h"
 
 #include "camera.h"
@@ -17,8 +18,24 @@
 #include "material.h"
 #include "sphere.h"
 
+camera& get_camera() {
+  static camera cam;
+  return cam;
+}
+
+EMSCRIPTEN_KEEPALIVE int get_height() {
+  return get_camera().image_height;
+}
+
+EMSCRIPTEN_KEEPALIVE int get_width() {
+  return get_camera().image_width;
+}
+
+EMSCRIPTEN_KEEPALIVE uint8_t* get_buffer() {
+  return get_camera().image_buffer;
+}
 
-int main() {
+EMSCRIPTEN_KEEPALIVE int run() {
     hittable_list world;
 
     auto ground_material = make_shared<lambertian>(color(0.5, 0.5, 0.5));
@@ -61,7 +78,7 @@ int main() {
     auto material3 = make_shared<metal>(color(0.7, 0.6, 0.5), 0.0);
     world.add(make_shared<sphere>(point3(4, 1, 0), 1.0, material3));
 
-    camera cam;
+    camera& cam = get_camera();
 
     cam.aspect_ratio      = 16.0 / 9.0;
     cam.image_width       = 1200;
@@ -77,4 +94,5 @@ int main() {
     cam.focus_dist    = 10.0;
 
     cam.render(world);
+    return 0;
 }

完成这些改动之后,我们就可以把这份代码编译成 wasm 二进制了!

$ emcc -o hello.wasm main.cc -Wall --no-entry -s ERROR_ON_UNDEFINED_SYMBOLS=0 -O3
$ file main.wasm 
main.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)

到此为止 “native” 侧相关的工作就已经都完成了。

编写 JavaScript 胶水代码

众所周知 WebAssembly 代码是不能被浏览器直接执行的,所以我们需要准备一点“胶水”代码,来加载 wasm 文件,并提供必需的外部定义函数:

WebAssembly.instantiateStreaming(fetch("main.wasm"), {
  env: {
    _Z13report_numberi: function (num) {
      console.log(`report_number called: ${num}`);
      const resultAddress = results.instance.exports._Z10get_bufferv();
      const width = results.instance.exports._Z9get_widthv();
      const height = results.instance.exports._Z10get_heightv();

      const memoryView = new Uint8ClampedArray(results.instance.exports.memory.buffer, resultAddress, width * height * 4)
      postMessage({
        kind: 'IMAGE_DATA',
        width,
        height,
        memoryView,
      })
    }
  },
  wasi_snapshot_preview1: {
    clock_res_get: function () { console.info('warning: clock_res_get called, function not implemented'); return 0 },
    clock_time_get: function () { console.info('warning: clock_time_get called, function not implemented'); return 0 },
    fd_write: function () { console.info('warning: fd_write called, function not implemented'); return 0 },
    fd_read: function () { console.info('warning: fd_read called, function not implemented'); return 0 },
    fd_close: function () { console.info('warning: fd_close called, function not implemented'); return 0 },
    fd_seek: function () { console.info('warning: fd_seek called, function not implemented'); return 0 },
    proc_exit: function () { console.info('warning: proc_exit called, function not implemented'); return },
    fd_fdstat_get: function () { console.info('warning: fd_fdstat_get called, function not implemented'); return 0 },
  }
}).then(
    (results) => {
      globalThis.results = results;

      console.time('wasm code')
      results.instance.exports._Z3runv();
      console.timeEnd('wasm code')

      const resultAddress = results.instance.exports._Z10get_bufferv();
      const width = results.instance.exports._Z9get_widthv();
      const height = results.instance.exports._Z10get_heightv();

      const memoryView = new Uint8ClampedArray(results.instance.exports.memory.buffer, resultAddress, width * height * 4)
      postMessage({
        kind: 'IMAGE_DATA',
        width,
        height,
        memoryView,
      })
    },
  );

其中,wasi_snapshot_preview1 传入了很多 C runtime 需要的函数,因为我十分确信代码没有使用这些函数,所以就挂了一堆垃圾进去。同时,在 env 中,我增加了一个 _Z13report_numberi 函数,执行的逻辑就是从若干 C 函数和参数中拿到画布的宽高,并将绘制结果通过 postMessage 函数传递给我们的 UI 线程。因为 WebAssembly 的执行也会阻塞其他代码,所以很显然这段 JavaScript 代码是在 worker 中执行的。

最后我们再准备一个入口的 HTML 文件就大功告成啦:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <canvas width="256" height="256" style="width: 256; height: 256;"></canvas>
    <script>
    const worker = new Worker("worker.js");
    worker.onmessage = (e) => {
      if (e.data.kind !== 'IMAGE_DATA') return;
      const imageData = new ImageData(e.data.memoryView, e.data.width, e.data.height, {});
      const canvas = document.querySelector('canvas')
      canvas.width = e.data.width;
      canvas.height = e.data.height;
      const ctx = canvas.getContext('2d');

      ctx.putImageData(imageData, 0, 0)
    }
    </script>
  </body>
</html>

亲自试一试

相关产物我也放到自己的 Blog 中,大家可以直接打开来查看效果!

image.png


网站公告

今日签到

点亮在社区的每一天
去签到