WebAssemblyで遊んでみる番外編〜Web Workerを使う〜

こんにちは、Y.Fです。

前回までは、Rustを使ったWebAssemblyの作成と、JavaScript側との協調などについて書いてきました。
前回の記事はこちら

toranoana-lab.hatenablog.com

今回は、少しWebAssemblyから離れて、Web WorkerによるCanvas描画及び、Worker内でのwasmファイルロードについて書いてみたいと思います。

Web Workerとは

そもそもWeb Workerとはなんぞや?という方もいると思います。端的に言うと、これはJavaScriptでマルチスレッドプログラミングを行うためのAPIになります。
JavaScriptはシングルスレッドで動作しています。 Promise などで非同期処理をしているとマルチスレッドで動いているかのように錯覚しますが、シングルスレッドです。

Web WorkerはJavaScriptの世界にマルチスレッドを持ち込む仕組みです。最近話題のPWAで使われているServiceWorkerもWeb Workerの一種です。
Canvas描画処理や、それに伴う幾何計算を別スレッドに逃がすことで、メインスレッドでの処理を高速化することが可能です。
また、メインスレッドでUI操作などがある場合に、上記のような重い処理でUI操作がブロックされなくなるなどのメリットもあります。

developer.mozilla.org

ただし、何でもありなわけではなく、Web Workerの利用には以下のような制限が存在します

  • DOM APIに直接さわれない
  • windowオブジェクトでさわれないAPIがいくつかある

Web Workerの使い方

ではとりあえず単純にWeb Workerを使ってみます。サンプルですが、上記MDNのページを参考にしました。

(index.html)

<html>
  <head>
    <title>WorkerTest</title>
    <meta charset="UTF-8">

  </head>
  <body>
    <script src="./main.js"></script>
  </body>
</html>

(main.js)

(function() {
  if (!window || !window.Worker) {
    return;
  }

  const worker = new Worker('worker.js');

  worker.postMessage([19, 222]);
  worker.onmessage = (e) => {
    console.log(`メッセージを受信しました(メインスレッド): ${e.data}`);
  };
})();

(worker.js)

onmessage = function(e) {
  console.log(`メッセージを受信しました: ${e.data}`);
  const result = `Result: ${e.data[0] * e.data[1]}`;
  console.log("メッセージをメインスレッドに返信します。");
  postMessage(result);
}

実行結果は以下のような感じです。

f:id:toranoana-lab:20190919122955p:plain
結果

ワーカーがどれだけ動いているかはChrome DevToolsでわかります。 f:id:toranoana-lab:20190919123401p:plain

ただ単に使うだけなら特に難しいことはありませんが、少し説明します。

  • const worker = new Worker('worker.js'); の部分
    • worker.js をダイナミックロードしてワーカーを起動します
  • onmessageイベント
    • postMessage関数が呼び出されると、このイベントで受け取られます

今回のサンプルではメインスレッドとワーカースレッドどちらもonmessageでメッセージを待ち受けて、相互にpostMessageでやり取りする形です。

スレッド間でのデータ転送

引数にデータを渡していましたが、スレッド間でデータをやり取りする場合は原則コピーされます。
したがって、巨大なデータを渡すとレスポンスが悪化する可能性が高いです。
そこで、Transferable インターフェイスを実装するデータについてはコピーしなくてもスレッド間でやり取り出来るようにされています。
現状では以下のようなものが存在しています。

  • ArrayBuffer
  • ImageBitmap
  • MessagePort
  • OffscreenCanvas

このOffscreenCanvasを使うことでDOMを触れないWeb WorkerでCanvasの更新が可能になります。

ソースを改善する

onmessageとpostMessageだけでやりくりするシンプルさは良いのですが、メッセージによって中身の処理を分けたいなど考え出すと、単純な実装ではif-elseだらけになってしまいそうです。
そこで、またMDNに登場してもらいます。これは、Web Workerでもう少し高度な情報やり取りなどをするためのサンプルです。

developer.mozilla.org

これを参考に、前回までのブロック崩しの描画処理をWeb Workerに逃がしてみます。

(main.ts)

import { BreakingBlocksTask } from "./tasks/breaking-blocks.task";

const logFunc = (msg: string) => {
  console.log(msg);
};

const alertFunc = (msg: string) => {
  alert(msg);
  document.location.reload();
};

/
export class Main {
  private myWorker: BreakingBlocksTask;

  public constructor() {
    this.myWorker = new BreakingBlocksTask();
    this.setupMainCanvas();
  }

  private setupMainCanvas() {
    this.myWorker.addListeners("log", logFunc);

    this.myWorker.addListeners("alert", alertFunc);

    this.myWorker.addListeners("setEvent", () => {
      document.addEventListener(
        "keydown",
        this.createEvent("keydown").bind(this),
        false
      );
      document.addEventListener(
        "keyup",
        this.createEvent("keyup").bind(this),
        false
      );
      document.addEventListener(
        "mousemove",
        this.createEvent("mousemove").bind(this),
        false
      );
      document.addEventListener(
        "click",
        this.createEvent("click").bind(this),
        false
      );
    });
  }

  private createEvent(func: string) {
    return (e: KeyboardEvent | MouseEvent) => {
      if (e instanceof KeyboardEvent) {
        this.myWorker.sendQuery(func, { key: e.key });
      } else {
        this.myWorker.sendQuery(func, { key: e.clientX });
      }
    };
  }

  public async show() {
    const canvas = document.getElementById("myCanvas") as HTMLCanvasElement;
    const offscreen = canvas.transferControlToOffscreen() as any;
    this.myWorker.sendQuery("drawStart", {
      transfers: [offscreen],
      offscreen: offscreen,
      offsetLeft: canvas.offsetLeft
    });
  }
}

(breaking-blocks.task.ts)

/* eslint-disable no-prototype-builtins */
import { Listener } from "../interfaces";

export class BreakingBlocksTask {
  private worker: Worker;
  private defaultListener: Listener = () => {};
  private listeners: { [keys: string]: Listener } = {
    terminate: () => {
      this.terminate();
    }
  };

  public constructor(onError = null, defaultListener?: Listener) {
    if (defaultListener) this.defaultListener = defaultListener;
    this.worker = new Worker("../workers/main/breaking-blocks.worker.ts", {
      name: "blocks",
      type: "module"
    });
    if (onError) this.worker.onerror = onError;
    this.worker.onmessage = event => {
      if (
        event.data instanceof Object &&
        event.data.hasOwnProperty("queryMethodListener") &&
        event.data.hasOwnProperty("queryMethodArguments")
      ) {
        this.listeners[event.data.queryMethodListener].apply(
          this,
          event.data.queryMethodArguments
        );
      } else {
        this.defaultListener(this, event.data);
      }
    };
  }

  public postMessage(message: string) {
    this.worker.postMessage(message);
  }

  public terminate() {
    this.worker.terminate();
  }

  public addListeners(name: string, listener: Listener) {
    this.listeners[name] = listener;
  }

  public removeListeners(name: string) {
    delete this.listeners[name];
  }

  public async sendQuery(
    method: string,
    {
      transfers,
      ...args
    }: { transfers?: Transferable[] | null; [key: string]: any }
  ) {
    const arrayArgs = Object.keys(args).map(function(key) {
      return args[key];
    });
    if (!method) {
      throw new TypeError("sendQuery takes at least one argument");
      return;
    }
    if (!transfers) {
      this.worker.postMessage({
        queryMethod: method,
        queryMethodArguments: arrayArgs
      });
    } else {
      this.worker.postMessage(
        {
          queryMethod: method,
          queryMethodArguments: arrayArgs
        },
        transfers
      );
    }
  }
}

(breaking-blocks.worker.ts)

/* eslint-disable no-prototype-builtins */
/* eslint-disable @typescript-eslint/camelcase */

// システム関数
function defaultReply(message: any) {
  postMessage({
    queryMethodListener: "log",
    queryMethodArguments: [message]
  });
}

function reply(...args: any) {
  if (args.length < 1) {
    throw new TypeError("reply - not enough arguments");
  }
  postMessage({
    queryMethodListener: args[0],
    queryMethodArguments: Array.prototype.slice.call(args, 1)
  });
}

const queryableFunctions: { [key: string]: any } = {
  drawStart: async (canvas: OffscreenCanvas, offsetLeft: number) => {
    await import("./draw.worker").then(logic => {
      const w = new logic.DrawWorker(canvas, reply);
      w.drawStart(canvas, offsetLeft, events => {
        // queryableFunctionsに設定されているものがメインスレッドから呼び出し可能なメソッドになるので、キーイベントの類はassignで後付する
        Object.assign(queryableFunctions, events);
        // メインスレッド側もこの時点でグローバル系のイベントセット
        reply("setEvent");
      });
    });
  }
};

onmessage = function(oEvent) {
  if (
    oEvent.data instanceof Object &&
    oEvent.data.hasOwnProperty("queryMethod") &&
    oEvent.data.hasOwnProperty("queryMethodArguments")
  ) {
    queryableFunctions[oEvent.data.queryMethod].apply(
      self,
      oEvent.data.queryMethodArguments
    );
  } else {
    defaultReply(oEvent.data);
  }
};

(draw.worker.ts)

/* eslint-disable no-prototype-builtins */
/* eslint-disable @typescript-eslint/camelcase */
import {
  speed,
  brick_width,
  brick_height,
  paddle_height,
  paddle_width,
  ball_radius,
  GameStatus,
  Paddle,
  Ball,
  Container,
  Status,
  update_all
} from "mdn-breaking-blocks-wasm";

const toHex = (v: number): string => {
  return ("00" + v.toString(16).toUpperCase()).substr(-2);
};

function hsl2rgb(h: number, s: number, l: number) {
  s = s / 100;
  l = l / 100;
  let rgb = [0, 0, 0];
  const c = (1 - Math.abs(2 * l - 1)) * s;
  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
  const m = l - c / 2;
  if (h >= 0 && h < 60) rgb = [c, x, 0];
  if (h >= 60 && h < 120) rgb = [x, c, 0];
  if (h >= 120 && h < 180) rgb = [0, c, x];
  if (h >= 180 && h < 240) rgb = [0, x, c];
  if (h >= 240 && h < 300) rgb = [x, 0, c];
  if (h >= 300 && h < 360) rgb = [c, 0, x];
  return rgb.map(v => (255 * (v + m)) | 0);
}

export class DrawWorker {
  private _events: {
    [key: string]: (val: any) => void;
  } = {};
  public get events(): {
    [key: string]: (val: any) => void;
  } {
    return this._events;
  }
  private reply: (...msg: any) => void;

  private ball: Ball;
  private paddle: Paddle;
  private status: GameStatus;
  private container: Container;
  private requestId: number = NaN;
  private readonly h = 240;

  public constructor(canvas: OffscreenCanvas, reply: (msg: any) => void) {
    this.reply = reply;
    this.status = GameStatus.new();
    this.container = Container.new();
    this.ball = Ball.new(
      2 * speed(),
      -2 * speed(),
      canvas.width / 2,
      canvas.height - ball_radius() * 2.0
    );
    this.paddle = Paddle.new((canvas.width - paddle_width()) / 2);
  }

  private drawBall(
    ctx: OffscreenCanvasRenderingContext2D,
    width: number,
    height: number
  ) {
    ctx.clearRect(0, 0, width, height);
    ctx.beginPath();
    ctx.strokeStyle = "black";
    ctx.lineWidth = 0.3;
    ctx.arc(
      this.ball.get_x(),
      this.ball.get_y(),
      ball_radius(),
      0,
      Math.PI * 2
    );
    ctx.fillStyle = "#FFFFFF";
    ctx.fill();
    ctx.stroke();
    ctx.closePath();
  }

  private drawBricks(ctx: OffscreenCanvasRenderingContext2D) {
    this.container.draw_with_callback((brickX: number, brickY: number) => {
      ctx.beginPath();
      ctx.strokeStyle = "#000000";
      ctx.lineWidth = 0.5;
      ctx.rect(brickX, brickY, brick_width(), brick_height());
      const [r, g, b] = hsl2rgb(brickY % 360, 80, 50);
      ctx.fillStyle = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
      ctx.fill();
      ctx.stroke();
      ctx.closePath();
    });
  }

  private drawPaddle(ctx: OffscreenCanvasRenderingContext2D, height: number) {
    {
      ctx.beginPath();
      ctx.fillStyle = "#f80";
      ctx.rect(
        this.paddle.get_x(),
        height - paddle_height(),
        paddle_width(),
        paddle_height()
      );
      ctx.fill();
      ctx.closePath();
    }
  }

  private drawScore(ctx: OffscreenCanvasRenderingContext2D) {
    ctx.font = "16px Arial";
    ctx.fillStyle = "#0095DD";
    ctx.fillText(`Score: ${this.status.get_score()}`, 8, 20);
  }

  private drawLives(ctx: OffscreenCanvasRenderingContext2D, width: number) {
    ctx.font = "16px Arial";
    ctx.fillStyle = "#0095DD";
    ctx.fillText(`Lives: ${this.status.get_lives()}`, width - 65, 20);
  }

  private update(width: number, height: number) {
    update_all(
      this.ball,
      this.paddle,
      this.status,
      this.container,
      width,
      height,
      (msg: string) => {
        this.reply("alert", msg);
        cancelAnimationFrame(this.requestId);
        this.reply("terminate");
      }
    );
  }

  private initCtx(canvas: OffscreenCanvas) {
    const ctx = canvas.getContext("2d");
    if (ctx === null || !(ctx instanceof OffscreenCanvasRenderingContext2D)) {
      this.reply("terminate");
      return null;
    }
    return ctx;
  }

  public async drawStart(
    canvas: OffscreenCanvas,
    offsetLeft: number,
    readyCallback: (event: { [key: string]: (val: any) => void }) => void
  ) {
    const ctx = this.initCtx(canvas);
    if (!ctx) return;

    const draw = () => {
      this.drawBall(ctx, canvas.width, canvas.height);
      this.drawBricks(ctx);
      this.drawPaddle(ctx, canvas.height);

      ctx.shadowOffsetX = 0;
      ctx.shadowOffsetY = 0;
      ctx.shadowBlur = 0;
      this.drawScore(ctx);
      this.drawLives(ctx, canvas.width);
      this.update(canvas.width, canvas.height);

      this.requestId = requestAnimationFrame(draw);
    };

    this._events = {
      keydown: (key: string) => {
        if (key === "Right" || key === "ArrowRight") {
          this.paddle.set_right_pressed(true);
        } else if (key === "Left" || key === "ArrowLeft") {
          this.paddle.set_left_pressed(true);
        }
      },

      keyup: (key: string) => {
        if (key === "Right" || key === "ArrowRight") {
          this.paddle.set_right_pressed(false);
        } else if (key === "Left" || key === "ArrowLeft") {
          this.paddle.set_left_pressed(false);
        }
      },
      mousemove: (clientX: number) => {
        const relativeX = clientX - offsetLeft;
        if (relativeX > 0 && relativeX < canvas.width) {
          this.paddle.set_x(relativeX - paddle_width() / 2);
          if (this.status.get_status() !== Status.Start) {
            this.ball.set_x(relativeX);
          }
        }
      },
      click: () => {
        this.status.set_status(Status.Start);
      }
    };
    readyCallback(this.events);
    draw();
  }
}

ソースの説明

  • main.ts
    • 各ワーカー用のコンテナクラスを制御するクラスです
    • ワーカーからメインスレッドへメッセージが来たときに発火したいメソッドなどはこちらから設定されます
  • breaking-blocks.task.ts
    • workerをメンバ変数に抱えるコンテナ用のクラスです
    • sendQuery でメンバ変数に抱えるワーカーへpostMessageします
    • listeners に設定されている関数がワーカーからpostMessageされたときのコールバックとして動きます
      • キーがイベント名、バリューが実際の関数になっています
  • breaking-blocks.worker.ts
    • ワーカー側のコンテナクラスです
    • ここでpostMessageを受け取り、実際のロジックを発火します
    • ロジック用のファイルをダイナミックインポートしてますが、これはwasm-packで作ったWebAssemblyはダイナミックインポートされる必要があるためです
  • draw.worker.ts
    • OffscreenCanvasへの描画処理及び、wasmファイルのロードを行います
    • イベントは直接は付けられないので、メインスレッドとの協調用にeventオブジェクトを付けたりしてます

問題点

完成形だけ見ると普通に動きそうなものですが、結構悩んだ部分もありました。
Workerコンストラクタに入れる引数はjsの実ファイル名となりますが、今回はTypeScriptで書いているため、コンパイルが必要です。
一方でts-loaderなどで普通にコンパイルしてしまうと、すべてのtsファイルが一つにバンドルされてしまいます。
今回は以下のファイルはそれ単体になって貰う必要があります。

  • breaking-blocks.worker.ts
  • draw.worker.ts

ここで、webpackのプラグインとして用意されている WorkerPlugin を利用することにします。

github.com

こんな感じに書くとよしなにビルドしてくれます。

/* eslint-disable @typescript-eslint/no-var-requires */
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const webpack = require("webpack");
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
const WorkerPlugin = require("worker-plugin");

module.exports = {
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".jsx", ".wasm"]
  },
  output: {
    globalObject: "this"
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: "ts-loader",
        options: {
          transpileOnly: true
        }
      },
      {
        test: /\.(jpg|png)$/,
        loader: "file-loader?name=[name].[ext]"
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.join(__dirname, "src/index.html")
    }),
    new WasmPackPlugin({
      crateDirectory: path.join(__dirname, "mdn-breaking-blocks-wasm"),
      outName: "mdn_breaking_blocks_wasm",
      withTypeScript: true
    }),
    // Have this example work in Edge which doesn"t ship `TextEncoder` or
    // `TextDecoder` at this time.
    new webpack.ProvidePlugin({
      TextDecoder: ["text-encoding", "TextDecoder"],
      TextEncoder: ["text-encoding", "TextEncoder"]
    }),
    // ココらへん追加
    new WorkerPlugin({
      plugins: ["WasmPackPlugin"]
    })
  ]
};

まとめ

今回は、いつもの記事と違い、JavaScript側によった内容となりました。
本当はOffscreenCanvas描画処理もRustに逃したかったのですが、OfscreenCanvasから作られるコンテキストである、 OffscreenCanvasRenderingContext2D のI/Fがwasm-bindgen側にまだ無いようだったので、エラーとなってしまい断念しました。
対応されたら全部Rustにしたものと比較してみたいです。

P.S.

虎の穴ラボは技術書典7に出展予定です!『協09』スペースでとらラボメンバーがお待ちしております。 配布物はいつもどおり無料配布予定ですので気軽にお越しください!

techbookfest.org

toranoana-lab.hatenablog.com

虎の穴では一緒に働く仲間を絶賛募集中です! この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。 yumenosora.co.jp

また、9/25(水)には2回目となる渋谷採用説明会を開催いたします。ご興味のある方は是非ご応募ください! yumenosora.connpass.com

さらに、10/9(水)には秋葉原採用説明会を開催いたします。こちらも、ご興味のある方は是非ご応募ください! yumenosora.connpass.com