TypeScriptの最新機能でWebGL/WebGPUリソースを自動解放する

# はじめに

この記事は[グラフィックス全般Advent Calender](https://qiita.com/advent-calendar/2024/graphics)２４日目の記事です。
 
WebGL/WebGPUを使っていると、つい疎かになるのがリソース解放ですね。

書き捨てのアプリならともかく、業務アプリなどでリソース解放を怠るなんてことは絶対にあってはいけません。

JavaScriptのオブジェクトはGCで自動的に破棄されますが、WebGLやWebGPUのリソースは明示的に破棄する必要があります。これらを自動処理にできたら嬉しくないでしょうか。

本記事では、自動破棄のための２つの方法を最新のTypescriptの機能を使ってご紹介します。

# アプローチ１：変数のusing宣言による自動解放

TypeScript 5.2から、変数宣言時にusingというものが使えるようになりました。

https://zenn.dev/mizchi/articles/practical-await-using

usingで変数宣言されたオブジェクトは、その変数スコープを抜けた際に指定した解放処理を確実に行ってくれます。ちょっとしたデストラクタっぽいですね。

解放処理は`[Symbol.dispose]`という名前のメソッドを定義することで指定できます。
実装方法には関数方式とクラス方式があります。

## 関数方式

リソースの生成関数（以下の例では`useWebGLTexture()`）を定義します。この生成関数は生成したリソースと`[Symbol.dispose]`という名前の解放関数を含んだオブジェクトを返すようにします。
この生成関数を呼び出し、帰ってきたオブジェクトを受ける際にはletやconstでなくusingで受けるようにします。

```typescript
// glオブジェクトはグローバルに宣言済みとする

function useWebGLTexture() {
  const texture = gl.createTexture();
  return {
    texture,
    [Symbol.dispose]() {
      console.log('[Symbol.dispose] called!')
      gl.deleteTexture(texture);
    },
  };
}

{
  using texObj = useWebGLTexture();
  /*
  * textureを使った処理をなんかやる
  * 
  * gl.bindTexture(gl.TEXTURE_2D, texObj.texture);
  */
  // このスコープを抜ける時に Symbol.dispose() が呼ばれる
}
console.log("WebGL Texture released!");
```

もし解放処理が非同期処理だった場合は`[Symbol.asyncDispose]`を使います。呼び出し側のコードはusingの前にawaitをつけます。
もし生成処理も非同期処理だった場合は、生成関数にasyncを付け、呼び出し側の関数呼び出しの前にawaitをつけます。

```typescript
// glオブジェクトはグローバルに宣言済みとする

async function useWebGLTexture() {
  const texture = gl.createTexture(); // 同期処理なのでasyncの必要はないのですが、単純な例として
  return {
    texture,
    async [Symbol.asyncDispose]() {
      console.log('[Symbol.dispose] called!')
      gl.deleteTexture(texture); // 同期処理なのでasyncの必要はないのですが、単純な例として
    },
  };
}

{
  await using texture = await useWebGLTexture();
  /*
  * textureを使った処理をなんかやる
  */
  // このスコープを抜ける時に Symbol.asyncDispose() が呼ばれる
}
console.log("WebGL Texture released!");
```

## クラス方式

クラス方式では、定義したクラスにDisposableというインターフェースを実装させます。実装すべきメンバメソッドは`[Symbol.dispose]`です。

```typescript
// glオブジェクトはグローバルに宣言済みとする

class AutoDisposableWebGLTexture implements Disposable {
  private texture: WebGLTexture;

  constructor(gl: WebGLRenderingContext) {
    this.texture = gl.createTexture();
  }

  [Symbol.dispose]() {
    gl.deleteTexture(this.texture);
  }

  // テクスチャを取得するメソッド
  getTexture(): WebGLTexture | null {
    return this.texture();
  }
}

// 使用例：
{
  using texture = new AutoDisposableWebGLTexture(gl);
  /*
   * textureを使った処理をなんかやる
   */
  // スコープを抜けると自動的にSymbol.dispose()が呼ばれる
}
console.log("WebGL Texture released!");
```

非同期版は以下です。

```typescript
// glオブジェクトはグローバルに宣言済みとする

class AutoDisposableWebGLTexture implements AsyncDisposable {
  private texture: WebGLTexture;

  constructor() {} // constructorにasyncはつけられないので、生成処理はinitメソッドでやる

  async init(gl: WebGLRenderingContext) {
    this.texture = gl.createTexture(); // 同期処理だけど、あえて単純な例として
  }

  async [Symbol.asyncDispose]() {
    gl.deleteTexture(this.texture); // 同期処理だけど、あえて単純な例として
  }

  // テクスチャを取得するメソッド
  getTexture(): WebGLTexture | null {
    return this.texture();
  }
}

// 使用例：
{
  await using texture = new AutoDisposableWebGLTexture();
  await texture.init(gl);
  /*
   * textureを使った処理をなんかやる
   */
  // スコープを抜けると自動的にSymbol.asyncDispose()が呼ばれる
}
console.log("WebGL Texture released!");
```

## このアプローチの制限

### using宣言を忘れると自動解放してくれない

利用側のコードがusing宣言を忘れ、varやlet, constなどで変数宣言してしまうと、`[Symbol.dispose]/[Symbol.asyncDispose]`が呼ばれず、自動解放してくれません。

この辺りは利用側にも注意が求められる（無条件での自動開放を保証してくれない）ので、やや不安がありますね。

### 現時点でのブラウザ制限

2024年12月現在、usingと`[Symobl.dispose]/[Symbol.asyncDispose]`はChromeのみサポートしており、FirefoxとSafariでは定義がないと言われ動作しませんでした。今後のサポートに期待ですね。

# アプローチ２：GC（ガベージコレクション）に伴う自動解放

JavaScriptはGCが動作していますが、もしWebGL/WebGPUのリソースを管理しているクラス（Textureクラスなど）のオブジェクトがどこからも参照されなくなった場合どうなるでしょうか。

GCが起きてクラスオブジェクトが開放された際、そのクラスが管理していたWebGL/WebGPUのリソースは開放されずじまいになってしまいます。

そこで、最近のモダンブラウザならサポートしている`FinalizationRegistry`を使うことで、GCのタイミングでコールバックを呼び出し、その中で開放処理を行うことができます。

```typescript
type FinalizationRegistryObject = {
  texture: WebGLTexture,
  textureId: number,
};

class AutoDisposableTexture {
  private gl: WebGLRenderingContext;
  private static textureId = 0;
  private texture: WebGLTexture;
  
  private static managedRegistry: FinalizationRegistry<FinalizationRegistryObject> =
    new FinalizationRegistry<FinalizationRegistryObject>((texObj) => {
      console.info(
        `WebGL texture ${texObj.textureId} was automatically released along with GC. But explicit release is recommended.`
      );
      this.gl.deleteTexture(texObj.texture);
    });

  constructor(gl: WebGLRenderingContext) {
    AutoDisposableTexture.textureId++;
    this.gl = gl;
    this.texture = gl.createTexture();
    AutoDisposableTexture.managedRegistry.register(this, { this.texture, AutoDisposableTexture.textureId });
  }
}
```

FinalizationRegistryのコールバックに渡す型はなんでも良いです。WebGLTexture単体で渡しても良かったのですが、もう少し汎用性を考えてオブジェクトにしました。今回のtextureIdはほぼ意味ないですが、まぁ例として。

実際に私のWeb3Dライブラリにおける動作例があります。

https://x.com/emadurandal_cgj/status/1856229026974838813

`FinalizationRegistry`のより詳しい使用方法はMDNを参照してください。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry

## 注意点

GC（ガベージコレクション）の挙動はブラウザによって異なります。特定の挙動の癖を前提にした処理は書くべきではありません。

また、リソースの解放処理をGCだけに頼るのも良くありません。GCの挙動には一貫性がなく、リソース解放のタイミングが運任せになってしまいます。また、参照がいつまでも切れなければGC対象にならず、リソース解放のタイミングはやってきません。

GCに連動したリソースの解放処理は、あくまで管理クラスが不意にGCで破棄された場合の救済対応として**補助的に**利用した上で、あくまで明示的な解放処理をきちんと実装し、メインでは明示的な解放処理の方を利用すべきでしょう。

## 自作ライブラリでFinalizationRegistryを利用する際に考慮したこと

### クラスオブジェクトの参照が切れることは通常は稀 → WeakRef（弱参照）を活用

JavaScript/TypeScriptで通常実装していると、リソースの管理クラス（Textureクラスなど）のオブジェクトの参照が完全に失われてGC対象となるというのは意外と稀です。GC対象にさせる場合は、あらかじめコードベースにおいてWeakRefなどの弱参照を活用して、参照を保持する主体を限定的にしておく必要があります。その主体からも参照が失われた場合、GCが発動します。その際、FinalizationRegistryのコールバックを実装していたならそれによるリソース解放処理が行われます。

私の自作Web3Dライブラリでは、上記のように実装することで、3Dオブジェクトを破棄した際に、それらが利用していたWebGL/WebGPUテクスチャリソースがGCと共に自動開放されるようにしました。

### 管理クラスオブジェクト自体もGCで破棄したかった

この実装は実験的なもので、やはり本来的にはGCに頼らず、明示的にリソース削除の仕組みを設けるべきとも思います（実際、設けています）。しかしJavaScriptはデストラクタがなくオブジェクトの破棄はGCだのみ、強制的な破棄ができません。明示的なリソース削除メソッドを管理クラス（Textureクラスなど）に持たせてそれを呼び出しても、普通の設計では管理クラスオブジェクト自体は生き続けてしまいます。大量のオブジェクトが生成されるようなケースでは、管理オブジェクト自体がメモリを圧迫することがあります。

そこで、管理クラスオブジェクトも破棄させるには、コードベース全体で弱参照を活用して、管理クラスオブジェクトがGC対象になりやすい設計を行うと良いのではないかと考えました。

こうすれば、群衆シミュなどのように、大量のオブジェクトの生成・破棄を行うようなアプリケーションでは、オブジェクトが少なくなった際にGCによる管理オブジェクトと内部リソースの自動破棄ができ、使用メモリ的に優しい設計になると考えました（GCが発動するとプチフリーズが起きるのでリアルタイム性は損なわれますが……）。

ここら辺は、思想次第かな、と思います。

## オブジェクトの参照を調べる方法

Chromeブラウザであれば、開発者コンソールの`Memory`タブから`Heap Snapshot`を取得することで、各オブジェクトがどこから参照されているか調べることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12753/2675198b-42ae-2396-63d8-8d804df9f98c.png)

私の場合は、これを使ってライブラリ内で無駄に参照をつかんでいる箇所を特定し、そこをWeakRefでの弱参照に置き換えることで管理クラスオブジェクトがGC対象になりやすいようにしました。

# まとめ

今回はコードをシンプルにしたいため、WebGLだけを例にしましたが、WebGPUでも同様のことができます。WebGPUは非同期処理のAPIがところどころあるので、usingアプローチにおいては[Symbol.asyncDispose]が活躍することでしょう。

JavaScript/TypeScriptも進化を重ね、リソースの自動解放の手段がついに使えるようになりました。適材適所で活用して、リソース解放漏れのない堅牢なコード設計を行いましょう。

さて、明日の記事は @emadurandal さんの「自作Web3DライブラリRhodonite ５年目の報告」です。

って俺じゃん（２回目）