LoginSignup
0
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-12-31

この記事は「続・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制御妄想1】SVG解析しないと始まらない
https://qiita.com/flying_echidna/items/5a628db0d652d1558208

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

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

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

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

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

【SVG制御妄想7】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行目から。

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行目から。

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

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行目から。

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行目から。

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行目から。

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行目から。

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行目から。

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行目から。

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行目から。

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

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

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

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1