【SVG制御妄想６】助けてマルチスレッド

この記事は「続・Mohoから出力したSVGを制御したい妄想の話」の一部です。

■実物
【GitHub】SourceOf0-HTML/path_control: SVGを制御したい願望
https://github.com/SourceOf0-HTML/path_control

【GitHub Pages】ベクターデータをいじり倒したい気持ち
https://sourceof0-html.github.io/path_control/


■記事一覧
【SVG制御妄想１】SVG解析しないと始まらない
https://qiita.com/flying_echidna/items/5a628db0d652d1558208

【SVG制御妄想２】Mohoから出力したSVGのマスクがバグる
https://qiita.com/flying_echidna/items/3930caf04626deec7bfb

【SVG制御妄想３】連番データをどげんかせんと
https://qiita.com/flying_echidna/items/ded3f3590c3d67fadb86

【SVG制御妄想４】変形させたいよなぁ？
https://qiita.com/flying_echidna/items/188634f35a05bbde9a51

【SVG制御妄想５】ボーンぐりぐり
https://qiita.com/flying_echidna/items/a34648da8a650fe34824

【SVG制御妄想６】助けてマルチスレッド
https://qiita.com/flying_echidna/items/80b101c1a1eedb534137

【SVG制御妄想７】SVGの限界
https://qiita.com/flying_echidna/items/2f53a461c5e6c05109df


■過去記事
【2019-03-06】「SVGでアニメーションさせたいんじゃ」の詳細報告
https://qiita.com/flying_echidna/items/ff3a061f4e348e62cca0

【2020-02-13】Mohoから出力したSVGを制御したい妄想の話 - Qiita
https://qiita.com/flying_echidna/items/da7ecc721650fa9ab651


#いろいろやりすぎて重すぎワロタ

何が一番重いって、ロードよね。
テスト用に使ってるSVGファイルの総数、1140枚。
これを一気にブラウザでロードしてるわけで。
だから仕方なしにSVGファイルをそのまま読み込んで使う処理とは別で、
一般公開用にバイナリ化して出力してるバージョンも用意したりもしてるわけで。

しかし、それだけじゃない。
実は、何の工夫もなくそのまま全部のSVGを読み込ませようとすると…

ブラウザがメモリ食いすぎてクラッシュする。

酷い。それはさすがに酷すぎる。
でも、クラッシュする原因がメモリの食いすぎだとしても、
うまいこと読み込みとメモリ開放をすれば、一気にメモリを食うこともないはず。
ということで、setTimeout()を使った時差読み込みをするようにした。

【GitHub】path_control/path_svg_loader.js ・ SourceOf0-HTML/path_control
https://github.com/SourceOf0-HTML/path_control/blob/18b79532596a558ba95cb23404e21107b536292f/src/path_svg_loader.js

442行目から。

```Javascript:path_svg_loader.js
    groupsDOMArr.forEach((targetDom, frame)=> {
      if(frame == 0) return;
      if(frame % 10 == 0) console.log("add action : " + actionName + " - " + frame);
      actionGroup.forEach((name, i)=> {
        setTimeout(this.addActionGroup, 0, i, targetDom.getElementById(name), name, frame, actionName);
      });
    });
    setTimeout(this.loadDOMEnd);
```

おかげでクラッシュはしなくなった。
…が。
余計に処理が遅くなった。そりゃそうだ。
時差読み込みするということは、一旦処理を終えて、もう一回走らせるようなもんで。
その間にJavaScriptのガベージコレクションが働いて、メモリを解放してくれていたとしても…
その分時間がかかるに決まってる。

実行までに5分かかるとか、勘弁ならん。
今時ブラウザのページの読み込みに5分とか、発狂じゃん。

おい！！
JavaScriptでマルチスレッドの実装はできんのか！！


#Web Workerなるものがあるぞ

WebWorker？なんそれ。

Web Worker の使用 - Web API | MDN
https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Using_web_workers

なんとまあ、JavaScriptでマルチスレッドできるってよ。
ということで、せっかくだから使ってみる。
…んだけども。
WebWorkerは、元々別のjsファイルのある場所を指定して、読み込ませるものなんだとか。
大量のコードを1つにまとめてミニファイしてやろうと企んでたもんだから、ファイル分割は若干痛手。
しかもHTMLで読み込むファイルパスとは違って、WebWorkerを初期化したJavaScriptの場所からのファイルパスになる模様。
カレントディレクトリがHTML側と異なってくる。めんどくさっ！
おまけに扱えるオブジェクトに制限があるとか。

Web Workers が使用できる関数とクラス - Web API | MDN
https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Functions_and_classes_available_to_workers

この中で一番痛いのは、WebWorkerからだとDOMがいじれないこと。
めんどくせぇな。


#別のjsファイルを読み込むとか嫌でござる

jsファイルをWebWorker用に分けたとしよう。
使うときは毎回複数あるjsファイルを一緒に置く必要が出てくる。
でも、どっちか欠けたら動かない。
事故る原因にもなるのに、ファイルをわけでどーすんだ。

だったらjsファイルの中でjsファイルを生成すればいいじゃない。

元々、実際にHTMLで読み込ませるjsファイルを一つにするために、バッチファイルでまとめるようにしてた。
このバッチファイル内で、WebWorkerにしたいjsファイルを文字列にしてしまって、Blobで読み込む、という荒業。

【GitHub】path_control/_comp.bat ・ SourceOf0-HTML/path_control
https://github.com/SourceOf0-HTML/path_control/blob/18b79532596a558ba95cb23404e21107b536292f/src/_comp.bat

このバッチファイル、デバッグバージョンや、SVGダイレクト読み込みバージョンのjsファイルを別々で生成するようにしてるんだけども…
28行目から。

```_comp.bat
set BIN_FILE=%DIR_PATH%path_control.js
del %BIN_FILE%
type LICENSE.txt >> %BIN_FILE%
echo let path_control = ` >> %BIN_FILE%
type %CORE_SRC% >> %BIN_FILE%
echo ` >> %BIN_FILE%
type path_main.js >> %BIN_FILE%
```

真ん中の行で突然path_controlという変数を宣言してる、これがそう。
ファイルをそのまま全部文字列化した挙句、変数にぶち込んでる。
で、こいつを…

【GitHub】path_control/path_main.js ・ SourceOf0-HTML/path_control
https://github.com/SourceOf0-HTML/path_control/blob/18b79532596a558ba95cb23404e21107b536292f/src/path_main.js

203行目から。

```Javascript:path_main.js
    let blob = new Blob([path_control], {type: "text/javascript"});
    let filePath = window.URL.createObjectURL(blob);
```

これでOK。
あとは「これはjsファイルですよ？何か？」と言わんがばかりに、
`new Worker(filePath);`
をすれば、WebWorkerが使える。


#で？ロードどうするよ？

問題はここ。
ロードはSVGをHTMLの中に展開することで行ってる。
でもそれはDOM操作なので、WebWorkerだけでは行えない。

じゃあDOM操作とデータ化をメインスレッドでして、時間のかかるファイルのロードをWebWorkerですればいいじゃない。
ということで…

【GitHub】path_control/path_load_svg_worker.js ・ SourceOf0-HTML/path_control
https://github.com/SourceOf0-HTML/path_control/blob/18b79532596a558ba95cb23404e21107b536292f/src/path_load_svg_worker.js

```Javascript:path_load_svg_worker.js
let fileInfoList = null;
let fileIndex = 0;
let getFrameNum=i=>("00000".substr(0, 5 - i.toString().length) + i + ".svg");

addEventListener("message", function(e) {
  let loadFile=()=> {
    let fileInfo = fileInfoList[fileIndex++];
    let kind = fileInfo[0];
    let totalFrames = fileInfo[1];
    let actionName = fileInfo[2];
    let filePath = fileInfo[3];
    
    let loadFrame = 1;
    let request = new XMLHttpRequest();
    let loadSVG = request.onreadystatechange = function(e) {
      let target = e.target;
      if(target.readyState != 4) return;
      if((target.status != 200 && target.status != 0) || target.responseText == "") {
        console.error("failed to read file: " + target.responseURL);
        console.error(target.statusText);
        return;
      }
      
      postMessage({
        cmd: "new-svg",
        actionName: actionName,
        frame: loadFrame,
        svg: target.responseText,
      });
      
      delete request;
      if(loadFrame <= totalFrames) {
        request = new XMLHttpRequest();
        //console.log(filePath + getFrameNum(loadFrame));
        request.open("GET", filePath + getFrameNum(loadFrame++), true);
        request.onreadystatechange = loadSVG;
        request.send();
        return;
      }
      
      if(fileIndex < fileInfoList.length) {
        postMessage({
          cmd: "load-add",
          kind: kind,
          actionName: actionName,
          totalFrames: totalFrames,
        });
      } else {
        postMessage({
          cmd: "load-complete",
          kind: kind,
          actionName: actionName,
          totalFrames: totalFrames,
        });
        close();
      }
    };
    //console.log(filePath + getFrameNum(loadFrame));
    request.open("GET", filePath + getFrameNum(loadFrame++), true);
    request.send();
  };
  
  let data = e.data;
  switch (data.cmd) {
    case "load":
      fileIndex = 0;
      fileInfoList = data.fileInfoList;
      loadFile();
      break;
      
    case "load-next":
      loadFile();
      break;
      
    default:
      console.error("unknown command: " + data.cmd);
      break;
  };
}, false);
```

指定されたSVGのファイルを片っ端から読み込むだけのWebWorker用jsファイル。
postMessage()でメインスレッドに途中経過とメッセージ（読み込んだSVGの文字列等）を渡してる。

【GitHub】path_control/path_svg_loader.js ・ SourceOf0-HTML/path_control
https://github.com/SourceOf0-HTML/path_control/blob/18b79532596a558ba95cb23404e21107b536292f/src/path_svg_loader.js

587行目から。

```Javascript:path_svg_loader.js
    this.loadWorker.addEventListener("message", function(e) {
      let data = e.data;
      switch(data.cmd) {
        case "load-complete":
          SVGLoader.loadFromDOM(data.kind, data.actionName, data.totalFrames);
          setTimeout(SVGLoader.loadEnd);
          break;
          
        case "load-add":
          SVGLoader.loadFromDOM(data.kind, data.actionName, data.totalFrames);
          break;
          
        case "new-svg":
          if(data.frame % 10 == 0) console.log("load file: " + data.actionName + " - " + data.frame);
          SVGLoader.addSvgDOM(data.svg);
          break;
          
        default:
          console.error("unknown command: " + data.cmd);
          break;
      }
    });
```

いいね。


#マルチスレッドできるなら描画処理分けたいよね

描画処理がメインスレッドで走ってると、何が嫌なのか。
仮に描画処理が重かったとしたら、入力処理が走るまで時間がかかる。
画面更新も完全に止まる。
パッと見で処理落ちが分かったり、場合によっては操作不要になる。
最近よく見る「ロード中」なんて画面中央でグルグルするやつなんかであれば、グルグルさせるための処理を走らせることすらできなくなる。
つまり？
ロード中画面は動かない静止画面になっちゃうし、カーソルは砂時計状態。
CSS側でアニメーションさせてたら関係ないけどね。
あくまでJavaScriptでロード用アニメーションを制御してた場合の話。

これからまだまだ処理が増えていく予定だし、描画処理もスレッドを分けてしまいたい。
ところが、WebWorkerでは今回描画に使っているCanvasを扱えない。
代わりにほぼほぼ同機能を持つ、OffscreenCanvasというものを代わりに使えば描画できる。

OffscreenCanvas - Web API | MDN
https://developer.mozilla.org/ja/docs/Web/API/OffscreenCanvas

参考サイトも張っとく。

オフスクリーンキャンバスを使ったJSのマルチスレッド描画 - スムーズなユーザー操作実現の切り札 - ICS MEDIA
https://ics.media/entry/19043/

これでゲロ重処理を分散できるぜぇー！！
…と思いきや。
これ、あくまで実験的な機能らしく、対応していないブラウザもめっちゃある。


#ブラウザが対応してないかもしれない問題どうするよ？

元々は動いてたのに、WebWorkerを導入したせいで動かなくなる、というのは忍びない。
あくまで処理速度（しかもメインはロード速度）を上げるためだけのものだから。

注目すべきはWebWorkerとメインスレッドとの連携方法。
postMessage()を使った、メッセージのやり取りによるもの、なんだけども…
やってることは結局、messageイベントの発行だったりする。

え？イベントの発行自体は、大抵のブラウザでもできるよね…？

【GitHub】path_control/path_main.js ・ SourceOf0-HTML/path_control
https://github.com/SourceOf0-HTML/path_control/blob/18b79532596a558ba95cb23404e21107b536292f/src/path_main.js

22行目から。

```Javascript:path_main.js
  postMessage: function(obj, opt) {
    if(PathMain.useWorker) {
      PathMain.worker.postMessage(obj, opt);
    } else {
      window.dispatchEvent(new CustomEvent("message", {bubbles: true, detail: obj}));
    }
  },
```

いやぁ、いつ見ても酷いねコレ。

PathMain.useWorkerは
`!!Worker && !!canvas.transferControlToOffscreen`
の結果が入ってる。つまり…
Workerというものが存在していて、
かつOffscreenCanvasのインスタンスを取得するためのtransferControlToOffscreen()が存在していれば、trueになる。
これで、このブラウザがOffscreenCanvasに対応してるかチェックしてる。

で、いけるんであれば元々予定していた`PathMain.worker.postMessage()`を使う。
んじゃ、対応してなかったら？
この場でmessageイベントを発行してる。
…ん？

実は、WebWorkerの初期化時の処理が分岐していて…
205行目から。

```Javascript:path_main.js
    if(PathMain.useWorker) {
      PathMain.worker = new Worker(filePath);
      if(!!jsPath) {
        PathMain.postMessage({
          cmd: "set-control",
          path: new URL(jsPath, window.location.href).href,
        });
      }
      PathMain.initWorker();
    } else {
      console.log("this browser is not supported");
      PathMain.worker = window;
      
      if(!!jsPath) {
        let subScript = document.createElement("script");
        subScript.src = jsPath;
        document.body.appendChild(subScript);
      }
      
      let mainScript = document.createElement("script");
      mainScript.src = filePath;
      document.body.appendChild(mainScript);
    }
```

if文の中身は、通常のWebWorker初期化処理の部分。
else文の中身では…
用意したjsファイルのパスを、HTMLのタグとして追加してる。
つまり？
`<script src="[ファイルパス]"></script>`
がHTMLの中に追加される。
ということは？
メインスレッドとして追加で読み込みされる。

なんとまあ、WebWorker用に用意したjsファイルを、そのままフツーのjsファイルとして読み込んでるのだ。
フツーに読み込んでしまえば、あとはフツーのイベント発行をすればいい。
ということでpostMessage()の
`window.dispatchEvent(new CustomEvent("message", {bubbles: true, detail: obj}));`
が用意されてる。

bubblesオプションは、メインスレッド内に複数存在してしまう
`addEventListener("message", ... )`
を巡回させるためのもの。
用事のないcmd名が指定されていた場合はスルーするようにしてる。

【GitHub】path_control/PathWorker.js ・ SourceOf0-HTML/path_control
https://github.com/SourceOf0-HTML/path_control/blob/18b79532596a558ba95cb23404e21107b536292f/src/org/PathWorker.js

21行目から。

```Javascript:PathWorker.js
  init: function() {
    PathWorker.instance.addEventListener("message", function(e) {
      let data = !e.data? e.detail : e.data;
      switch(data.cmd) {
```

WebWorkerに対してpostMessage()を呼び出すとdataにメッセージが入ってるんだけど、
dispatchEvent()だとdetailに入ってるので、そこをチェックした上で、
switch文で分岐させて、メッセージ通りの処理を実行する。

我ながらごり押しすぎる。ワロス。

#CanvasとOffscreenCanvasの両方に対応していくスタイルッ

仮にうまくWebWorker内に読み込めたとしても、
WebWorkerからCanvasで直接表示することはできず、OffscreenCanvasを使う必要がある。
ブラウザが対応してないなら、従来通りCanvasで表示する必要がある。
とはいえ、OffscreenCanvasの機能はほぼCanvasと同じ。
じゃあ同じ変数に入れてもいいよね？と。

【GitHub】path_control/path_main.js ・ SourceOf0-HTML/path_control
https://github.com/SourceOf0-HTML/path_control/blob/18b79532596a558ba95cb23404e21107b536292f/src/path_main.js

125行目から。

```Javascript:path_main.js
    let targetCanvas = PathMain.canvas;
    let targetSubCanvas = PathMain.subCanvas;
    if(PathMain.useWorker) {
      targetCanvas = targetCanvas.transferControlToOffscreen();
      targetSubCanvas = targetSubCanvas.transferControlToOffscreen();
    }
    
    PathMain.postMessage({
      cmd: "init",
      viewWidth: viewWidth,
      viewHeight: viewHeight,
      canvas: targetCanvas,
      subCanvas: targetSubCanvas,
      defaultBoneName: PathMain.defaultBoneName,
    }, [ targetCanvas, targetSubCanvas ]);
```

PathMain.canvasも、PathMain.subCanvasも、
`document.createElement("canvas")`
で作ったCanvasタグで、subCanvas側は非表示したまま描画処理をしておいて、表示するタイミングでsubCanvasの内容をcanvasにコピペするようにしてる。
いわゆるダブルバッファリングってやつ。これで一応ちらつき防止になるのだ。
で、transferControlToOffscreen()を使うことで、各々に対応したOffscreenCanvasを生成できる。
あとはcanvasであれ、OffscreenCanvasであれ、postMessage()で投げつけてWebWorkerの初期化処理をする、と。

よく見るとpostMessage()の第2引数に
`[ targetCanvas, targetSubCanvas ]`
がついてる。ここもポイント。

postMessage()で渡したデータはあくまでメッセージとしてコピーで生成されたものなので、通常共有されない。
だから渡したデータは「同じ内容の違うデータ」なわけだ。
なので、受け取った側でいくらいじっても、送った側のデータに変化は起こらない。

一方、この第2引数で指定されたデータは、WebWorkerとメインスレッドで共有して扱うことができる。
ただし、なんだって共有できるわけじゃない。
OffscreenCanvasが渡せるデータの一つというだけである。


#やべ、外部から制御する処理どこに用意しよう？

元々は、読み込ませたSVGによって追加で制御したい処理があったときに、JavaScriptで追記する形にしようかと思ってたわけで。
例えば、徐々に横に移動させる処理を追加するとか、回転させる処理を追加するとか。
カーソルで制御するか、キーボードで制御するか、とか。
そういうの。

ところがぎっちょん。
データをWebWorkerで持たせてしまうと、HTML側やHTMLで読み込んだJavaScript側の処理では、WebWorker内のデータにアクセスできない。
でも、WebWorkerにはimportScripts()という、新しいjsファイルを読み込む機能が、あるにはある。
んじゃこれを使おう。

もひとつぎっちょん。
しまった、WebWorker内だとカレントディレクトリが変わるんだった。
相対パスのままじゃ渡せないな…
ということで、206行目から。

```Javascript:path_main.js
      PathMain.worker = new Worker(filePath);
      if(!!jsPath) {
        PathMain.postMessage({
          cmd: "set-control",
          path: new URL(jsPath, window.location.href).href,
        });
      }
      PathMain.initWorker();
```

用意したWebWorkerにpostMessage()で指定されたjsファイルのパスを渡す。
この際に、今開いてるHTMLのURL（window.location.href）をベースとした絶対パス（URL）へ変換しておく。
あとは、受け取ってimportScripts()するだけ。

【GitHub】path_control/PathWorker.js ・ SourceOf0-HTML/path_control
https://github.com/SourceOf0-HTML/path_control/blob/18b79532596a558ba95cb23404e21107b536292f/src/org/PathWorker.js

80行目から。

```Javascript:PathWorker.js
        case "set-control":
          importScripts(data.path);
```

これで追加のjsファイルをWebWorker内で実行できる。
好き放題いじれる。やったぜ。

次の記事：【SVG制御妄想７】SVGの限界
https://qiita.com/flying_echidna/items/2f53a461c5e6c05109df