こんにちは、Y.Fです。
前回までは、Rustを使ったWebAssemblyの作成と、JavaScript側との協調などについて書いてきました。
前回の記事はこちら
今回は、少しWebAssemblyから離れて、Web WorkerによるCanvas描画及び、Worker内でのwasmファイルロードについて書いてみたいと思います。
Web Workerとは
そもそもWeb Workerとはなんぞや?という方もいると思います。端的に言うと、これはJavaScriptでマルチスレッドプログラミングを行うためのAPIになります。
JavaScriptはシングルスレッドで動作しています。 Promise
などで非同期処理をしているとマルチスレッドで動いているかのように錯覚しますが、シングルスレッドです。
Web WorkerはJavaScriptの世界にマルチスレッドを持ち込む仕組みです。最近話題のPWAで使われているServiceWorkerもWeb Workerの一種です。
Canvas描画処理や、それに伴う幾何計算を別スレッドに逃がすことで、メインスレッドでの処理を高速化することが可能です。
また、メインスレッドでUI操作などがある場合に、上記のような重い処理でUI操作がブロックされなくなるなどのメリットもあります。
ただし、何でもありなわけではなく、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); }
実行結果は以下のような感じです。
ワーカーがどれだけ動いているかはChrome DevToolsでわかります。
ただ単に使うだけなら特に難しいことはありませんが、少し説明します。
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でもう少し高度な情報やり取りなどをするためのサンプルです。
これを参考に、前回までのブロック崩しの描画処理を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
を利用することにします。
こんな感じに書くとよしなにビルドしてくれます。
/* 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』スペースでとらラボメンバーがお待ちしております。 配布物はいつもどおり無料配布予定ですので気軽にお越しください!
虎の穴では一緒に働く仲間を絶賛募集中です! この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。 yumenosora.co.jp
また、9/25(水)には2回目となる渋谷採用説明会を開催いたします。ご興味のある方は是非ご応募ください! yumenosora.connpass.com
さらに、10/9(水)には秋葉原採用説明会を開催いたします。こちらも、ご興味のある方は是非ご応募ください! yumenosora.connpass.com