Featured image of post 利用 WebAssembly 在浏览器内实现光线追踪

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

动手玩一玩 WebAssembly

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

安装 Emscripten

Emscripten 是 WebAssembly 的工具链,我们需要手动安装。这个过程并不复杂,可以查看官方文档来了解详细的安装方法。简单来说,我们只需要执行以下命令即可完成安装和激活:

1
2
3
4
5
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」的源码

这是一本比较有名的光线追踪入门科普读物(?),大家可以点击这个链接来阅读: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 中补充,来实现上面优化中的第二点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@@ -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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@@ -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 函数改了个名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@@ -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 二进制了!

1
2
3
$ 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 文件,并提供必需的外部定义函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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 文件就大功告成啦:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!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 中,大家可以直接打开这个链接来查看效果!

Screenshot of Safari viewing the rendered image with JavaScript console open

comments powered by Disqus
Except where otherwise noted, content on this blog is licensed under CC-BY 2.0.
Built with Hugo
主题 StackJimmy 设计