Read OSS

ノードシステムとThree Shading Language:グラフからシェーダーを構築する仕組み

上級

前提知識

  • 記事1〜3
  • DAG/グラフデータ構造の理解
  • 基本的なシェーダープログラミングの知識(vertex/fragmentステージ、uniform、varying)

ノードシステムとThree Shading Language:グラフからシェーダーを構築する仕組み

ノードシステムは、現代のThree.jsにおける最も野心的なエンジニアリングの成果です。マテリアル・ライト・エフェクトの組み合わせごとに個別のGLSLシェーダーテンプレートを管理するWebGLRendererのアプローチとは異なり、新しいレンダラーはシェーダーをNodeオブジェクトの有向非巡回グラフ(DAG)として構築します。これらのグラフはNodeBuilderによって三段階のパイプラインで走査され、WebGPU向けのWGSLまたはWebGLフォールバック向けのGLSLを出力します。さらに、TSL(Three Shading Language)は流暢なJavaScript APIを提供し、カスタムシェーダーのロジックをグラフデータ構造の管理ではなく数式を書く感覚で記述できるようにします。

Nodeベースクラスと型システム

NodeはEventDispatcherを継承し、すべてのシェーダーグラフノードの基底クラスとして機能します。コンストラクタはノードの出力型を表すnodeType文字列を受け取ります。使用できる値は'float''vec2''vec3''vec4''mat4''bool'などで、src/nodes/core/constants.jsで定義されています:

export const NodeType = {
    BOOLEAN: 'bool',
    INTEGER: 'int',
    FLOAT: 'float',
    VECTOR2: 'vec2',
    VECTOR3: 'vec3',
    VECTOR4: 'vec4',
    MATRIX2: 'mat2',
    MATRIX3: 'mat3',
    MATRIX4: 'mat4'
};

ノードのupdate()メソッドが呼ばれるタイミングは、NodeUpdateTypeで定義された三つのライフサイクルプロパティによって制御されます。NONE(呼ばれない)、FRAME(フレームごとに一度)、RENDER(レンダーコールごとに一度——シャドウや反射など一フレーム内に複数のレンダーが発生することがある)、そしてOBJECT(このノードを使用するオブジェクトごとに一度)の四種類です。

ビルドプロセスは66行目で宣言された三つのフェーズで構成されています:

export const defaultBuildStages = [ 'setup', 'analyze', 'generate' ];
classDiagram
    class Node {
        +nodeType: string
        +updateType: string
        +version: number
        +global: boolean
        +setup(builder): Node
        +analyze(builder): void
        +generate(builder): string
        +build(builder): string
    }

    class ConstNode {
        +value: any
        +generate(): string
    }

    class OperatorNode {
        +op: string
        +aNode: Node
        +bNode: Node
    }

    class MathNode {
        +method: string
        +generate(): string
    }

    class MaterialNode {
        +scope: string
    }

    Node <|-- ConstNode
    Node <|-- OperatorNode
    Node <|-- MathNode
    Node <|-- MaterialNode

重要な実装の詳細として、10行目にある_parentBuildStageマップがステージの順序を定義しています。analyzeの親はsetupgenerateの親はanalyzeです。これにより、子ノードが親から参照される前に正しいステージで処理されることが保証されます。

ノードのカテゴリ:Accessor、Math、Lighting、Display

src/nodes/ディレクトリはノードを機能ごとに整理しています:

カテゴリ ディレクトリ 用途
Core core/ 基底クラスとインフラ Node, NodeBuilder, ConstNode, StackNode
Accessors accessors/ シーン/オブジェクトのデータを読み取る MaterialNode, CameraNode, PositionNode, NormalNode
Math math/ 数学演算 MathNode, OperatorNode, CondNode
Lighting lighting/ ライト計算 LightsNode, AnalyticLightNode, ShadowNode
Display display/ 出力処理 ToneMappingNode, ColorSpaceNode, ViewportDepthNode
Code code/ 生のシェーダーコードの注入 FunctionNode, ExpressionNode
TSL tsl/ Three Shading Languageランタイム TSLCore, TSLBase
Functions functions/ 再利用可能なシェーダー関数 BRDF_GGX, getAlphaHashThreshold

Accessorノードはシーングラフのデータをシェーダーに取り込みます。MaterialNodeはマテリアルプロパティ(color、roughness、metalness)を読み取り、CameraNodeはカメラのuniform(位置、プロジェクション行列)を読み取ります。PositionNodeは各種座標空間(ローカル、ワールド、ビュー)での頂点位置を提供します。

Mathノードは演算を担当します。OperatorNodeは二項演算子(+、−、×、÷)を処理し、MathNodeは関数(sin、cos、normalize、mix、smoothstep)を処理します。CondNodeは三項選択を実装します。

graph TD
    subgraph "Accessor Nodes"
        MC["materialColor"] --> MulOp
        T["texture(map)"] --> MulOp
    end

    subgraph "Math Nodes"
        MulOp["OperatorNode(*)"] --> AddOp["OperatorNode(+)"]
        E["materialEmissive"] --> AddOp
    end

    subgraph "Display Nodes"
        AddOp --> TM["ToneMappingNode"]
        TM --> CS["ColorSpaceNode"]
        CS --> Output["output"]
    end

TSL:流暢なシェーダー記述のためのランタイムメタプログラミング

TSLは、ノードグラフの構築をJavaScriptでシェーダーの数式を書くような感覚で行えるようにする開発者向けAPIです。その仕組みの核心は、TSLCore.js内のaddMethodChaining()にあります:

export function addMethodChaining( name, nodeElement ) {
    if ( NodeElements.has( name ) ) {
        warn( `TSL: Redefinition of method chaining '${ name }'.` );
        return;
    }

    NodeElements.set( name, nodeElement );

    if ( name !== 'assign' ) {
        Node.prototype[ name ] = function ( ...params ) {
            return this.isStackNode 
                ? this.addToStack( nodeElement( ...params ) ) 
                : nodeElement( this, ...params );
        };

        Node.prototype[ name + 'Assign' ] = function ( ...params ) {
            return this.isStackNode 
                ? this.assign( params[ 0 ], nodeElement( ...params ) ) 
                : this.assign( nodeElement( this, ...params ) );
        };
    }
}

この関数は二つのことを同時に行います。ノードのファクトリー関数をNodeElementsマップに登録し、Node.prototypeにメソッドをパッチしてチェーン可能な呼び出しとして追加します。positionLocal.mul(2.0)と書いたとき、実際には次のことが起きています:

  1. positionLocalNode(具体的にはPositionNode
  2. .muladdMethodChaining('mul', mulFunction)によってNode.prototypeに追加されたメソッド
  3. プロトタイプメソッドがmulFunction(this, 2.0)を呼び出し、新しいOperatorNode('*', positionLocal, float(2.0))を生成して返す

これは純粋なランタイムメタプログラミングです——コンパイルステップもbabelプラグインも必要ありません。TSLの関数呼び出しはすべてノードを構築して返すため、式は自然に合成できます:

// This JavaScript expression:
positionLocal.mul( 2.0 ).add( offset ).normalize()

// Produces this node DAG:
// MathNode('normalize',
//   OperatorNode('+',
//     OperatorNode('*', positionLocal, float(2.0)),
//     offset
//   )
// )

ヒント: TSLの式は遅延評価です——グラフを構築するだけで、シェーダーコードを実行するわけではありません。グラフがシェーダーコードになるのは、マテリアルのコンパイル時にNodeBuilderがグラフを走査したときです。つまり、TSLの式を変数として保存して再利用することができます。

NodeBuilder:DAGからシェーダーへのコンパイラ

NodeBuilderは、ノードグラフをシェーダーのソースコードに変換するコンパイラの基底クラスです。3Dオブジェクト、レンダラー、パーサーを引数に取り、変数の宣言、uniformのバインディング、varyingの管理、コードの出力といったインフラストラクチャを提供します。

三つのビルドフェーズは次のように機能します:

  1. Setup:ノードが子ノードを返し、DAGの構造を確立します。ノードは自己変換することもあります——たとえば、マテリアルにカラーマップがある場合、MaterialNodeのsetupはTextureNodeを返すことがあります。

  2. Analyze:ビルダーがDAGを走査し、各ノードが属するシェーダーステージ(vertexまたはfragment)を決定し、varyingにする必要がある共有ノードを検出し、uniformの宣言を収集します。

  3. Generate:各ノードがシェーダーコードの文字列を出力します。OperatorNode(a * b)を、MathNodenormalize(x)を、accessorノードはuniformの読み取りや属性のルックアップを生成します。

flowchart LR
    subgraph "Phase 1: Setup"
        S1["Material slots → Node DAG"]
    end

    subgraph "Phase 2: Analyze"
        S2["Stage assignment<br/>Varying detection<br/>Uniform collection"]
    end

    subgraph "Phase 3: Generate"
        S3["Node → shader code string<br/>Variable declarations<br/>Code assembly"]
    end

    S1 --> S2 --> S3

    S3 --> WGSL["WGSLNodeBuilder<br/>→ WGSL code"]
    S3 --> GLSL["GLSLNodeBuilder<br/>→ GLSL code"]

NodeBuilderは二つの具体的な実装にサブクラス化されています:

  • WGSLNodeBuilder(約2,523行):WebGPU向けのWGSLを生成
  • GLSLNodeBuilder(約1,676行):WebGLフォールバック向けのGLSLを生成

どちらも同じマテリアルから同じノードグラフを受け取ります——DAGはバックエンドに依存せず、コード生成のみがバックエンド固有です。

NodeMaterial:ノードとマテリアルの接点

NodeMaterialはベースのMaterialクラス(Part 2で解説)を継承し、スロットシステムを追加します。スロットシステムとは、マテリアルパイプラインのあらゆる段階を制御するnull許容のノードプロパティの集合です:

スロット 用途 デフォルト
colorNode ディフューズカラー マテリアルのcolorプロパティ
normalNode 法線の摂動 ジオメトリの法線
opacityNode アルファ値 マテリアルのopacity
emissiveNode 自己発光 マテリアルのemissiveカラー
roughnessNode PBRのroughness マテリアルのroughness
metalnessNode PBRのmetalness マテリアルのmetalness
outputNode 最終的なfragment出力 計算済みのライティング結果
positionNode 頂点位置のオーバーライド ジオメトリの位置

各スロットは、期待される型に解決できるノードであれば何でも受け付けます。material.colorNode = texture(myTexture).mul(color(0xff0000))と設定すると、デフォルトのカラーロジックが、テクスチャをサンプリングして赤く色づけするTSLの式に置き換えられます。

MeshStandardNodeMaterialなどの具体的なサブクラスは、PBR固有のスロットのデフォルト値を設定し、roughnessマップ、metalnessマップ、ノーマルマップ、環境マップのサンプリング用に追加のスロットを提供します。

flowchart TB
    subgraph "NodeMaterial Slots"
        PN["positionNode"] --> VS["Vertex Shader"]
        CN["colorNode"] --> FS["Fragment Shader"]
        NN["normalNode"] --> FS
        ON["opacityNode"] --> FS
        EN["emissiveNode"] --> FS
        RN["roughnessNode"] --> FS
        MN["metalnessNode"] --> FS
    end

    VS --> Rasterizer["Rasterization"]
    Rasterizer --> FS
    FS --> OutputN["outputNode"] --> Final["Final Color"]

StandardNodeLibrary:後方互換性の要

StandardNodeLibraryは、WebGLRenderer向けに書かれたシーンと新しいWebGPURendererの後方互換性を実現する仕組みです。すべてのクラシックなマテリアルとライトの型をノードベースの対応物にマッピングします:

this.addMaterial( MeshStandardNodeMaterial, 'MeshStandardMaterial' );
this.addMaterial( MeshPhysicalNodeMaterial, 'MeshPhysicalMaterial' );
this.addMaterial( MeshBasicNodeMaterial, 'MeshBasicMaterial' );
// ... 10 more material mappings

this.addLight( PointLightNode, PointLight );
this.addLight( DirectionalLightNode, DirectionalLight );
this.addLight( SpotLightNode, SpotLight );
// ... 6 more light mappings

this.addToneMapping( acesFilmicToneMapping, ACESFilmicToneMapping );
// ... 5 more tone mapping mappings

WebGPURendererがシーン内のMeshStandardMaterialに遭遇すると、NodeManagerStandardNodeLibraryを参照します。対応するMeshStandardNodeMaterialを見つけ、ノードベースの等価物を透過的に生成します。ユーザーのコードは一切変更不要です。ライブラリがその変換を処理します。

ヒント: WebGLRendererからWebGPURendererへの移行時に、マテリアルを書き直す必要はありません。StandardNodeLibraryが自動的に変換を処理します。ただし、ノードシステムの真の力(カスタムシェーダーエフェクト、compute shader、プロシージャルジオメトリ)を最大限に活用したい場合は、NodeMaterialとTSLを直接使用しましょう。

ウォークスルー:TSL式からシェーダーコードへ

シンプルなTSLの式がシェーダーコードになるまでの過程を追ってみましょう。次のコードを例にします:

material.colorNode = materialColor.mul( texture( map ) );

ステップ1:グラフの構築。 materialColorはマテリアルのcolorプロパティを読み取るMaterialNodeです。texture(map)TextureNodeを生成します。.mul()OperatorNode('*', materialColorNode, textureNode)を生成します。

ステップ2:Setupフェーズ。 NodeBuilderがルートノードのsetup()を呼び出します。OperatorNodeは自身を返し、子ノードを再帰的にセットアップします。TextureNodeは依存関係としてUVアクセサーノードを生成することがあります。

ステップ3:Analyzeフェーズ。 ビルダーがテクスチャのサンプリングはfragmentステージで行う必要があると判断します。vertexステージからの入力がある場合、それらの接続をvaryingとしてマークします。

ステップ4:Generateフェーズ。 各ノードがコードを出力します。WGSLの場合、次のような出力が得られます:

let nodeColor = nodeUniform.color * textureSample(nodeTexture, nodeSampler, nodeVarying_uv);

GLSLでは、同じグラフから次のコードが生成されます:

vec4 nodeColor = nodeUniform_color * texture(nodeTexture, nodeVarying_uv);

このアーキテクチャの強みは、「マテリアルカラーとテクスチャサンプルを乗算する」という同一のセマンティクスグラフが、どのターゲット言語に対しても正しいコードを生成できる点にあります。uniform、sampler、varying、座標変換といった配管作業はすべて自動的に処理されます。

次のステップ

レンダラーとノードシステムをカバーしたところで、次はサポートインフラストラクチャに目を向けます。すべてのトランスフォームとシェーディング計算を支える数学ライブラリ、カメラとライトの階層構造、カラーマネジメント、そしてシェーダーベースのライティング計算においてライトがノードシステムとどのように統合されているかを解説します。