TSL(Three.js Shader Language)入門

## はじめに

この記事は、Three.jsで導入される[Three.js Shader Language(TSL)](https://github.com/mrdoob/three.js/wiki/Three.js-Shading-Language)について、その概要と基本的な使い方を記録、共有するためのものです。

### 対象とする読者

- Three.jsの基本的な使い方を理解している
- シェーダーに興味がある
- GLSLの基本的な知識がある

### 対象環境

- Three.js r165
- [WebGPUが利用可能なブラウザー](https://caniuse.com/webgpu)
- TypeScript v5.4.5
- Node.js v20.14.0

### この記事を読む前に

:::note warn
TSLの仕様は現在ドラフト版と位置付けられています。ユーザーからのフィードバックを受け付けている段階で、将来的には仕様が大きく変更されるかもしれません。Three.jsとブラウザーのバージョンを確認してからお読みください。
:::

この記事では、JavaScriptの開発環境やThree.jsのインストールガイドは取り扱いません。Three.jsの基本的な使い方については、[公式ドキュメント](https://threejs.org/docs/index.html#manual/en/introduction/Installation)を参照してください。

## TSLとは

Three.js Shader Language（TSL）は、Three.jsが新たに推進するシェーダー言語です。2024/05/29にThree.jsから最初の仕様が[アナウンス](https://x.com/threejs/status/1795726636526288979)されました。

### TSLに関連する用語

まず、TSLに関連する用語を整理します。

- `Node` : 値の変更を検知し、演算を行い、複数のNodeを合成するオブジェクト
- `NodeMaterial` : 複数のNodeをメンバー変数に持つマテリアル。Nodeを入れ替えることでレンダリング結果が変わる
- `WebGPURenderer` : NodeMaterialをオンザフライでWGSL(WebGPU)やGLSL(WebGL2)に変換するレンダラー

これらの要素を総合してTSLと呼びます。

### なぜ新しいシェーダー言語が必要なのか

現在、Three.jsはWebGPUへの段階的移行と、シェーダー作成の難易度を下げるという2つの目標を掲げています。この目標を達成するために、TSLが導入されました。

#### WebGPUへの移行

Three.jsは[WebGL](https://developer.mozilla.org/ja/docs/Web/API/WebGL_API)コンテンツをJavaScriptで記述するためのライブラリです。

WebGLでは最新のGPUを最大限に活用できません。この問題を解決するために[WebGPU](https://www.w3.org/TR/webgpu/)が開発されています。

WebGPUはシェーダー言語に[WGSL(WebGPU Shading Language)](https://www.w3.org/TR/WGSL/)を採用しています。この言語はWebGLで使われてきた[GLSL(OpenGL Shading Language)](https://developer.mozilla.org/ja/docs/Games/Techniques/3D_on_the_web/GLSL_Shaders)とは互換性がありません。

Three.jsは1つのソースからWGSLとGLSLの両方を出力する中間言語としてTSLを導入しました。

#### Shader Chunkの複雑化

現状のThree.jsは、シェーダーの処理を共通化するために[ShaderChunk](https://github.com/mrdoob/three.js/tree/dev/src/renderers/shaders/ShaderChunk)という仕組みを採用しています。これはGLSLコードを文字列として扱い、レンダリング直前に置き換えるというものです。

[Three.js内蔵のShaderChunk](https://github.com/mrdoob/three.js/tree/master/src/renderers/shaders/ShaderChunk)に依存するシェーダーは、その変更によって動作しなくなります。たとえばShaderChunk内の変数名が1つ変わっただけで、そのChunkに依存する後続処理が失敗します。

TSLはJavaScriptで記述されるため、WGSLへ変換する際にTree Shakingが適用されます。ShaderChunkと違い、シェーダーの処理は入出力以外が隠蔽され、変更による影響範囲が限られます。

### GLSLとTSLの関係性の整理

従来のGLSLで記述されたシェーダーマテリアルとTSLの関係性を整理します。

- WebGLRenderer → ShaderMaterial → GLSL
- WebGPURenderer → NodeMaterial →（変換）→ WGSL / GLSL

[NodeMaterial](https://github.com/mrdoob/three.js/blob/dev/examples/jsm/nodes/materials/NodeMaterial.js)にNodeを渡すと、WebGPURendererがNodeをWGSL(WebGPU)またはGLSL(WebGL2)に変換します。

WebGLRendererではNodeMaterialは[サポートされません](https://github.com/mrdoob/three.js/wiki/Migration-Guide#163--r164)。NodeMaterialはWebGPURendererでのみサポートされます（[関連issue](https://github.com/mrdoob/three.js/pull/27156)）。

### TSLの目標

TSLが実現されると、以下のような利点があります。

#### WebGL2とWebGPUで動作するシェーダーマテリアル

Three.jsは以下の手順でWebGPUへの移行を進めています。

- レンダラーをWebGLRendererからWebGPURendererへ移行する
- WebGPUが動作しない環境では、WebGPURendererがWebGL2にフォールバックする

カスタマイズしたGLSLに依存しないコンテンツは、WebGLRendererをWebGPURendererに入れ替えるだけで移行が完了します。GLSLで記述したシェーダーをTSLに移行すれば、WebGPUがサポートされていない環境でもコンテンツが動作するようになります。

#### シェーダーの再利用性向上

TSLはシェーダーの処理をノードとして切り出せます。

- 基本となるノードマテリアルを安全に継承し、カスタマイズできる
- 頻繁に利用するシェーダーをノードに切り出すことで、複数のマテリアルに再利用できる

#### ノードエディター

NodeMaterialはシェーダーの処理がNodeに切り出されており、ビジュアルシェーダーエディターが実現可能です。将来的には、Three.jsでもUnityの[Shader Graph](https://docs.unity3d.com/ja/Packages/com.unity.shadergraph@10.0/manual/index.html)のようなエディターが利用できるかもしれません。

ブラウザー上で動作するThree.js用のノードエディターは、[Three.js PlayGround](https://threejs.org/playground/)というページで公開されています。

[参考Pull Request](https://github.com/mrdoob/three.js/pull/23508)

![Three.js NodeEditor](https://user-images.githubusercontent.com/502810/154416376-b34a9e74-c591-412c-bacf-9faa3092ccec.png)

## TSLの基本

ミニマムなNodeMaterialを作成し、TSLの基本を理解しましょう。

### セットアップ

Three.jsからTSLを利用するために、関連するクラスをimportします。

```js
import { Mesh, PlaneGeometry } from "three";
import { MeshBasicNodeMaterial } from 'three/nodes';

const material = new MeshBasicNodeMaterial();
const mesh = new Mesh(new PlaneGeometry(10, 10), material);
```

シェーダーをカスタマイズしないNodeMaterialを作成しました。このマテリアルは、`MeshBasicMaterial`と同じように動作します。

#### TypeScriptの場合、importに注意

```ts
import { MeshBasicNodeMaterial } from 'three/src/nodes/Nodes.js';
```

Three.js r167は、[package.jsonのexportsフィールド](https://github.com/mrdoob/three.js/blob/b04c758f49e4e82a1f5ba48a07b18ef711d77a06/package.json#L18)で`webgpu`もしくは`tsl`というサブパスを指定しています。公式ドキュメントではこのサブパスでサンプルコードが記述されています。

:::note warn
TypeScriptを使う場合、tsconfig.jsonの`moduleResolution`フィールドが`node`だとexportsフィールドが解決できません。
:::

- サブパスを使わず`three/src/nodes/Nodes.js`を直接importする
- tsconfig.jsonの`moduleResolution`を`bundler`もしくは`node16`にして、threeのimportをすべて`webgpu`サブパスに統一する。

この問題を解決するためには、上記のいずれかの方法を選択してください。

### レンダリング結果を変更する

[NodeMaterial](https://github.com/mrdoob/three.js/blob/b30715ae263344207b6affc400124d31454160c2/examples/jsm/nodes/materials/NodeMaterial.js)には、メンバー変数として[Node](https://github.com/mrdoob/three.js/blob/b30715ae263344207b6affc400124d31454160c2/examples/jsm/nodes/core/Node.js)が組み込まれています。これらのNodeを置き換えると、レンダリング結果が変わります。

```ts
import { Mesh, PlaneGeometry } from "three";
import { MeshBasicNodeMaterial, color } from 'three/nodes'; // colorノードを生成する関数をimport

const material = new MeshBasicNodeMaterial();
material.colorNode = color(0xff0000); // カラーノードを赤色に変更
const mesh = new Mesh(new PlaneGeometry(10, 10), material);
```

https://masatomakino.github.io/threejs-lab/demo/StudyNodeMaterial_Basic.html

このコードでは、`colorNode`に固定値を割り当てて赤色に変更しています。TSLの`color`は、Nodeを生成するための関数です。[NodeMaterialのコンストラクター](https://github.com/mrdoob/three.js/blob/b30715ae263344207b6affc400124d31454160c2/examples/jsm/nodes/materials/NodeMaterial.js#L32-L66)内でたくさんのNodeが初期化されていますが、まずは以下の3つのNodeを覚えてください。

- `colorNode` : マテリアルに適用される色を決定するノード
- `fragmentNode` : カラーノードにライトやフォグの影響を合わせたノード
- `positionNode` : ジオメトリの座標を出力するノード

他にもopacity、shadow、normalなどのNodeがあります。目指す表現にあわせて、これらのNodeを入れ替えましょう。

### マテリアルの設定値をNodeとして扱う

マテリアルに設定された値をNodeとして扱いたい場合は、[materialColor](https://github.com/mrdoob/three.js/blob/dev/examples/jsm/nodes/accessors/MaterialNode.js#L374)などのアクセサー関数を使います。

```ts
import { Mesh, PlaneGeometry, Color } from "three"; // three.jsのColorクラスをimport
import { MeshBasicNodeMaterial, materialColor } from 'three/nodes'; // TSLのuniformを生成する関数をimport

const material = new MeshBasicNodeMaterial();
const mesh = new Mesh(new PlaneGeometry(10, 10), material);
material.colorNode = materialColor; // カラーノードにマテリアルのcolor変数を割り当てる。

material.color.setHex(0x00ff00); // マテリアルの設定を変更すると、レンダリング結果に反映される。
```

ほかにもopacityなどのアクセサー関数が用意されています。

### 変数を扱う

Nodeマテリアルのレンダリングに成功しましたが、固定値を割り当てたためマテリアル設定が変更できません。次に、変数を扱う方法を解説します。

#### Uniform

`uniform`はJavaScriptからWebGPUに変数を渡すためのNodeです。

```ts
import { Mesh, PlaneGeometry, Color } from "three"; // three.jsのColorクラスをimport
import { MeshBasicNodeMaterial, color, uniform } from 'three/nodes'; // TSLのuniformを生成する関数をimport

const uniformColor = uniform(new Color(0xff0000)); //Colorインスタンスを参照するuniformノードを作成
const material = new MeshBasicNodeMaterial();
const mesh = new Mesh(new PlaneGeometry(10, 10), material);
material.colorNode = uniformColor; // カラーノードにuniformノードを割り当てる。

uniformColor.value.setHex(0x00ff00); // uniformノードの値を変更すると、レンダリング結果に反映される。
```

https://masatomakino.github.io/threejs-lab/demo/StudyNodeMaterial_ColorUniform.html

この例では、`uniformColor`というuniformノードを作成し、`Color`インスタンスを割り当てました。このuniformノードを`colorNode`に割り当てることで、マテリアルの色を動的に変更できます。

`uniform`ノードには`value`というメンバーがあり、ここでuniformに割り当てたインスタンスが格納されています。この値を変更すると、次のレンダリング時に値がGPUに転送されます。

##### 可変長配列にはuniformArray

可変長配列をuniformにしたい場合は[uniformArray](https://github.com/mrdoob/three.js/pull/28910)ノードを使います。

```ts
import { Mesh, PlaneGeometry, Vector4 } from "three";
import { MeshBasicNodeMaterial, uniformArray } from 'three/nodes'; // uniformArrayをインポート

const vectors = uniformArray( [
	new THREE.Vector4( 1, 0, 1, 1 ),
	new THREE.Vector4( 0, 1, 0, 1 ),
	new THREE.Vector4( 0, 0, 1, 1 )
] ); //uniformArrayには配列を渡す

const material = new MeshBasicNodeMaterial();
const mesh = new Mesh(new PlaneGeometry(10, 10), material);
material.colorNode = vectors.element(0);
  //uniformArrayには[]ではなくelementでアクセスする。
  //elementの引数にはNodeも渡せるので、attributeを参照したり、別のuniformを参照できる。
```

#### Attribute

`attribute`はジオメトリの頂点情報をフラグメントシェーダーで受け取るためのNodeです。BufferGeometryには、デフォルトで座標や頂点カラーなどのAttributeが登録されていますが、カスタムアトリビュートを追加することで、頂点ごとに異なる値をシェーダーに渡せます。

```ts
const geometry = new PlaneGeometry(10, 10);
const customAttribute = new BufferAttribute(
    new Float32Array(geometry.attributes.position.array),
    3
);
customAttribute.setXYZ(0, 0, 0, 0);
customAttribute.setXYZ(1, 1, 0, 0);
customAttribute.setXYZ(2, 0, 1, 0);
customAttribute.setXYZ(3, 1, 1, 1);

geometry.setAttribute("customColorAttribute", customAttribute); //ジオメトリに"customColorAttribute"という名前でアトリビュートを追加する。

const material = new MeshBasicNodeMaterial();
material.colorNode = color(attribute("customColorAttribute")); //カラーノードからカスタムアトリビュートを参照する。
const mesh = new Mesh(geometry, material);
```

https://masatomakino.github.io/threejs-lab/demo/StudyNodeMaterial_Attribute.html

この例ではcolorNode内でカスタムアトリビュートを参照し、頂点ごとに異なる色を割り当てています。

#### オペレーター

JavaScriptでは演算子オーバーロードができません。そのため、TSLではWGSLの演算子を関数として定義しています。

```ts
import { float } from 'three/nodes'; 
const operated = float(1).add(2); // = 1 + 2
```

各関数は戻り値としてNode自身を返します。そのため、メソッドチェーンで演算を連続できます。

#### tslFn（TSL関数）

tslFn（TSL関数）は、複数のNodeを参照して、演算結果を返すNodeを生成する関数です。以下のような用途に利用できます。

- ノイズ関数のような、複数のマテリアルで再利用したい処理をノード化する
- 複数のノードを入力として受け取り、合成するノードを生成する

tslFnは処理に必要なNodeを引数として受け取ります。ShaderChunkのように他のChunkで設定されているはずの変数に依存することがなく、再利用性が高くなります。

## 個人的な感想

この記事ではTSLの背景と基本的な使い方を紹介しました。従来のShaderMaterialには再利用性の問題がありましたが、TSLではその問題が改善しそうです。TSLに慣れておくと、WebGPUへの移行がスムーズになりそうです。