WebGL / WebGPU公開 約13分

WebGL リソースの解放:作り直しと初期化失敗の後始末をそろえる

画面の部品を外しても、作成したbufferやtextureの参照を失うだけで終えていませんか。資源を二つ持つ小さな教材で、入れ替え前の解放と、途中まで作れた場合の片付けを確かめます。

この記事の目次
  1. 変数を空にする前に、所有者へ片付けを頼む
  2. 失敗する場所も選べる、小さな実験
  3. 作り直す前の一行を抜くと、前の組を見失う
  4. 完成した組だけを解放すると、途中のbufferが残る
  5. 最後に解放してから、もう一度作れるか

表示を切り替えるたびにWebGLのbufferやtextureを作るなら、前の部品をいつ終わらせるかも必要です。新しい値を同じ変数へ代入するだけでは、前に作った資源を明示的に片付ける場所がなくなります。作成と解放を、一つの部品の仕事としてまとめてみましょう。

JavaScriptの関数、クロージャー、tryとcatchが分かる人向けです。WebGL 1でbufferとtextureを一つずつ作り、入れ替えと解放を文字で観察します。画像の描画やシェーダーは使いません。GPUのメモリー量を測る教材でもなく、作ったハンドルの有効性と削除の呼び出しを確かめます。

変数を空にする前に、所有者へ片付けを頼む

JavaScriptからWebGLの資源を操作するためのオブジェクトを、ここではハンドルと呼びます。bufferを入れていた変数をnullにしても、その行がdeleteBufferを呼ぶわけではありません。不要になった時点で削除を明示すると、後始末をブラウザーの回収任せにせず書けます。

MDNのWebGLの実装指針でも、不要になったハンドルを早めに削除する方針が紹介されています。ただし削除を要求した瞬間に、使用中の内部資源や物理的なGPUメモリーまで必ず解放済みになる、という意味ではありません。

今回の所有者はcreatePairで作る一組です。その組だけが自分のbufferとtextureを削除します。ページ全体のcanvasや、別の部品が所有する資源まで消しません。イベント登録の後始末と寿命をそろえる考えは似ていますが、リスナーを解除してもWebGLの資源は削除されません。

操作 呼び出す順番 最後に残すもの
最初の作成 一組を作成 新しい一組
作り直し 前の組を解放 → 新しい組を作成 新しい一組
解放 現在の組を解放 → 参照を外す なし
作成途中で失敗 途中まで作った分を解放 なし

失敗する場所も選べる、小さな実験

次をresources.htmlとして保存して開きます。canvasは1×1で非表示です。画面へ描画しないため、DPRやリサイズで描画領域を増やす処理はありません。WebGLが利用できなければ、操作ボタンを無効のままにして文章を残します。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGLの作成と解放</title>
<style>
body { margin: 16px; font-family: sans-serif; line-height: 1.8; }
main { max-width: 640px; margin: auto; }
button { font: inherit; margin: 4px; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
:focus-visible { outline: 3px solid #174ea6; outline-offset: 3px; }
</style>
</head>
<body>
<main>
<h1>資源を一組ずつ作り直す</h1>
<p>描画を行わず、bufferとtextureの寿命を確かめます。</p>
<button id="create" type="button" disabled>作成・作り直し</button>
<button id="release" type="button" disabled>解放</button>
<button id="fail" type="button" disabled>texture作成の失敗を再現</button>
<pre id="result" role="status">JavaScriptとWebGLを確認しています。</pre>
<canvas id="gpu" width="1" height="1" hidden></canvas>
</main>
<script>
const canvas = document.querySelector('#gpu');
const result = document.querySelector('#result');
const buttons = [...document.querySelectorAll('button')];
const gl = canvas.getContext('webgl');
let active = null;

function createPair(failTexture = false) {
  let buffer = null;
  let texture = null;
  let disposed = false;
  function dispose() {
    if (disposed) return;
    disposed = true;
    if (texture) gl.deleteTexture(texture);
    if (buffer) gl.deleteBuffer(buffer);
  }
  function describe() {
    return `buffer: ${gl.isBuffer(buffer)}, texture: ${gl.isTexture(texture)}`;
  }
  try {
    buffer = gl.createBuffer();
    if (!buffer) throw new Error('bufferを作成できません');
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    texture = failTexture ? null : gl.createTexture();
    if (!texture) throw new Error('textureを作成できません');
    gl.bindTexture(gl.TEXTURE_2D, texture);
    if (gl.isContextLost()) throw new Error('WebGLを失いました');
    return { dispose, describe };
  } catch (error) {
    dispose();
    throw new Error(error.message + '\n後始末後 ' + describe());
  } finally {
    gl.bindBuffer(gl.ARRAY_BUFFER, null);
    gl.bindTexture(gl.TEXTURE_2D, null);
  }
}

function replace(failTexture) {
  active?.dispose();
  active = null;
  try {
    active = createPair(failTexture);
    result.textContent = '作成後 ' + active.describe();
  } catch (error) {
    result.textContent = error.message;
  }
}

if (gl) {
  buttons.forEach(button => { button.disabled = false; });
  result.textContent = '現在の組:なし';
  document.querySelector('#create').addEventListener('click', () => replace(false));
  document.querySelector('#fail').addEventListener('click', () => replace(true));
  document.querySelector('#release').addEventListener('click', () => {
    if (!active) {
      result.textContent = '現在の組:なし';
      return;
    }
    active.dispose();
    active.dispose(); // 二重に呼んでも片付けは一度だけ
    result.textContent = '解放後 ' + active.describe();
    active = null;
  });
} else {
  result.textContent = 'この環境ではWebGLを利用できません。';
}
canvas.addEventListener('webglcontextlost', () => {
  active?.dispose();
  active = null;
  buttons.forEach(button => { button.disabled = true; });
  result.textContent = 'WebGLを失いました。再読み込みしてください。';
});
</script>
</body>
</html>

bufferとtextureをそれぞれ一度バインドしてから、有効性を調べています。作成しただけの状態と混同しないためです。KhronosのWebGL 1仕様で、これらの操作とisBufferなどの判定を確認できます。参照先は編集草案です。今回の例はデータを転送せず、画像や頂点の領域を確保する処理まで進めません。

describeが返すtrue・falseは、APIから見たハンドルの有効性です。メモリー使用量や解放したバイト数ではありません。また、このcanvasではほかの部品が同時にバインド状態を使わないため、finallyでnullへ戻しています。共有の描画エンジンへ組み込むなら、そのエンジンの状態管理に合わせます。

作り直す前の一行を抜くと、前の組を見失う

一つ目の失敗は、replaceの冒頭にあるactive?.dispose()を省くことです。新しい組を代入すると、前の組のdisposeへ届く参照がなくなります。最後の組を解放してfalseが出ても、以前の組に対して削除を呼んだ証明にはなりません。

修正は、参照を外す前に前の組を解放することです。調べるときはdisposeのdisposedを調べた後へ一時的にconsole.count('pair disposed')を入れ、再読み込みしてから「作成・作り直し」を三回、解放を一回押します。カウントは順に、初回なし、1、2、3となるのが期待結果です。

同じdisposeを二度呼ぶと、二度目は先頭で戻ります。deleteBufferとdeleteTextureも、削除済みの対象への再度の削除に効果はありません。それでも関数側で一度にそろえると、後で追加する別の後始末も重複させずに済みます。

完成した組だけを解放すると、途中のbufferが残る

二つ目の失敗は、解放をreplace側にだけ書き、createPair内のcatchからdisposeを外すことです。textureを作る前に失敗すると、完成した組はreturnされません。呼び出し側のactiveはnullのままなので、先に作ったbufferをそこから片付けられません。

修正は、作成を担当する関数内で途中の資源も把握し、失敗時に同じdisposeを呼ぶことです。「texture作成の失敗を再現」を押し、後始末後のbufferとtextureがどちらもfalseになるか見ます。catch内のdisposeを抜いた練習用の版ではbufferがtrueになるため、差を確かめたら元へ戻し、ページを再読み込みしてください。

このボタンはtextureの作成がnullを返す状況を意図的に作っています。実機のメモリー不足を起こしているわけではありません。保存失敗を代役で再現する記事と同様、失敗後の分岐を確かめる手段です。WebGLのすべての異常がJavaScriptの例外になるわけでもないため、catchだけであらゆるGPUエラーを検知したとは扱いません。

最後に解放してから、もう一度作れるか

正常作成では両方true、解放直後では両方false、途中失敗の後も両方falseになるのが三つの判定です。さらに失敗の後に正常作成を押して両方trueへ戻ること、解放を連続して押すと「現在の組:なし」となることを確認します。操作の順序も記録してください。

この例は、部品を作り直す前に古い表示を捨てる方針です。新しいものが完成するまで前の表示を残したいなら、新規作成が成功してから入れ替える設計へ変えます。また、全体で共有するtextureを複数部品が使うなら、一部品の終了だけで削除せず、共有資源の所有者へ返す必要があります。

コンテキスト喪失後の復元や、描画中のアニメーション停止は別の課題です。この教材は再読み込みの案内で止めます。canvasのサイズ調整へ組み込む場合も、表示領域を変える処理と資源の寿命を分けてください。JavaScriptの無料レッスンから部品を増やす際には、作成に対応する後始末を誰が呼ぶかまで一組で説明できれば、同じ考え方を使えます。

TRY A LESSON

記事の内容を、課題で確かめる

前提になる表示サイズの練習 →

WebGL・3Dの発展課題 →

記事についてのお問い合わせ

記載内容へのご質問や訂正のご連絡は、hello@junkbranding.comまでお願いします。

← 記事一覧に戻る