Unity WebGLで使われているシェーダを抜き出してARBアセンブリを眺める


最近GPUを作りたいんだけど、良いアプローチを思いついていない。懐しのARB Assemblyくらいなら行けるんじゃないかということでUnityアプリをWebGLビルドし、そのシェーダを抜き出してnVidiaのCg Toolkit(懐しいな)で処理してアセンブリにしてみた。

結果、WebGLの世界と言えども 流石にDirectX 8時代のテクノロジでは少々厳しい ということが判った。

ARB Assembly

いわゆるGPUシェーダはDirectX8で導入され、DirectX9でHLSL(C言語風のシェーダ記述言語)が導入され 今に至る (= WebGL1ではDirectX9 世代のGPU機能しか使えない)が、ARB AssemblyはちょうどDX8とDX9の中間あたりの機能性を提供する。

命令セットは比較的コンパクトで、ピクセルシェーダで 33命令、頂点シェーダで27命令が定義されている。いくつかのポイントは:

  • 今でも使える 。実はこのOpenGL拡張は今でも大抵のDesktop GL環境でサポートされている。 ...MojoShaderみたいにGLSLに変換してから実行する処理系になっているかもしれないけど。
  • 分岐が存在しない 。分岐命令がなく、プログラムの実行速度が一定になる。これはWebGLでも前方分岐しか許されていないのでWebGLを実装する上では大きな障害にはならない。
  • 命令セットは頂点シェーダとピクセルシェーダでほぼ同じ。

DX10やOpenGL 2.0以降はアセンブリのサポートは無くなったが、VulkanではSPIR-Vが導入され命令を直接書くことは一応可能になった。

spector.js 拡張でWebGL描画をトレースする

というわけで、ARB Assemblyが現代に蘇えったとして、どのくらい戦えるのかを知りたい。これにはWebGLなゲームを動かしてARB Assemblyにコンパイルしてみるのが良いかなと思った。WebGLはちょうどDirectX9での実行を想定してOpenGL ESをサブセットしているので、WebGLに対応したものであれば打率が高まるはず。

この手の処理にはspector.js( https://spector.babylonjs.com/ )が便利。拡張をインストールするとブラウザにボタンが追加され、クリックすることで キャプチャ取得モードでページをリロード → さらにクリックでキャプチャメニュー表示 となる。

今回はある程度複雑なシェーダを使おうということで、Unity-chan Toon Shader2 ( https://github.com/unity3d-jp/UnityChanToonShaderVer2_Project ) のサンプルをWebGLビルドして使った。ビルド設定はForward renderingを強制するためにWebGL1ビルドに設定している。(Graphics APIsで、WebGL 1.0だけを残す。)

通常のWebサイト同様、Chromeに表示させて、キャプチャボタンを押せばキャプチャできる。

... この時点で何か描画おかしくないか。。? アウトライン抽出がおかしい気がする。

キャプチャしたデータは、json形式でエクスポートできる。

シェーダの切り出し

// Extract WebGL1 shaders from spector.js trace output "capture.json"

const path = require("path");
const fs = require("fs");

const capture = JSON.parse(fs.readFileSync(path.resolve(__dirname, "capture.json")).toString("utf8"));

capture.commands.forEach(cmd => {
    if(cmd.DrawCall){
        cmd.DrawCall.shaders.forEach(s => {
            const ext = (s.SHADER_TYPE == "VERTEX_SHADER") ? ".vert" : ".frag";
            fs.writeFileSync(s.shader.__SPECTOR_Object_TAG.id.toString() + ext,
                             s.source);
        });
    }
});

適当にこういう感じで切り出す。切り出したものは拡張子 .frag (フラグメントシェーダ、いわゆるピクセルシェーダ) と .vert (バーテックスシェーダ) のどちらかになる。

このシーンだけで42個ものシェーダが出力された。実際には内容が同じものもあるだろうけど、大きいものでは28KiBとわりとヤバいサイズになっていた。

Cg Toolkit でGLSLをコンパイルする

nVidiaのCg Toolkit( https://developer.nvidia.com/cg-toolkit )は一般に普及したC言語風シェーダコンパイラとしては始祖にあたるもので、2012年でHLSLやGLSLにその役割を譲ってメンテナンスを終了した。

これ自体はnVidiaの公式シェーダ言語フロントエンドという側面もあり、 実はGLSLのコンパイラも兼ねている

で、コンパイラはDirectX 8〜9世代のベンダ間共通GPUアセンブリである ARB_vertex_program とか ARB_fragment_program 向けのアセンブリコードも出力できる。

$ cgc -ogles -profile arbvp1 28.vert # 頂点シェーダの場合arbvp1を使う
$ cgc -ogles -profile arbfp1 23.frag # ピクセルシェーダの場合

コンパイルしたシェーダはこういう感じのアセンブリ命令列になり、

SLT R0.x, R0, -c[3].w;
ABS R0.x, -R0;
CMP R0.x, -R0, c[3].y, c[3];
MOV result.color, c[3].x;
KIL -R0.x;

GLSLコンパイラを実装するよりはマシかなという気持ちになれる。

エラーを眺める

が、流石にDirectX9世代のWebGL1とDirectX8〜9世代のARB Assemblyでは機能的に格差があり正常にコンパイルできなかった。

LODと微分

大きいピクセルシェーダでは大体次のように失敗する:

23.frag
23.frag(494) : error C3004: function "vec4 tex2D(sampler2D, vec2, vec2, vec2);" not supported in this profile
23.frag(494) : error C3004: function "vec2 ddx(vec2);" not supported in this profile
23.frag(494) : error C3004: function "vec2 ddy(vec2);" not supported in this profile
597 lines, 3 errors.

これは多分 EXT_shader_texture_lod で、常識的なGL環境ではサポートされている... ってWebGL Statsだと全然サポートされてないな。。( https://webglstats.com/webgl/extension/EXT_shader_texture_lod )

エラーになっている行は:

#version 100
#ifdef GL_EXT_shader_texture_lod
#extension GL_EXT_shader_texture_lod : enable
#endif
#if !defined(GL_EXT_shader_texture_lod)
#define texture2DLodEXT texture2D
#endif

    u_xlat10_4.xyz = texture2DLodEXT(_MatCap_Sampler, u_xlat4.xy, _BlurLevelMatcap).xyz;

これは マットキャップ の貼り付けで使われるパラメタで、

Mip Map機能を利用して、MatCap_Samplerをぼかします。Mip Mapを有効にするためには、テクスチャインポートセッティングで、Advanced > Generate Mip MapsON にしてください。デフォルトは0(ぼかさない)です。

とりあえずゼロ据え置き(LOD無視)で良いか。ただ、まだエラーになる:

$ cgc -ogles -profile arbfp1 23a.frag
23a.frag
(0) : error C6007: Constant register limit exceeded; more than 32 constant registers needed to compiled program

... DX8世代のハードウェアにはシェーダが複雑すぎるようだ。無限のリソースを仮定して無理矢理コンパイルしてみると:

$ cgc -ogles -profile arbfp1 -po MaxLocalParams=9999 23a.frag
...
#var half _GI_Intensity :  : c[96] : -1 : 1
...
#const c[103] = 0.074279785 -0.2121582 1.5703125
PARAM c[104] = { program.local[0..96],
                { 1, -1, 0, 0.41674805 },
                { 1.0546875, -0.054992676, 0.5, 2 },
                { 0.29907227, 0.58691406, 0.11401367, 3 },
                { 10, 0, -3, 0.049987793 },
                { 0.0010004044, 0.00010001659, -10, 11 },
                { -2, -0.5, 3.140625, -0.018722534 },
                { 0.074279785, -0.2121582, 1.5703125 } };
# 453 instructions, 13 R-regs

圧巻のパラメタ104個、453命令。。手元の環境(Intel内蔵GPUのOpenGL)では MAX_PROGRAM_LOCAL_PARAMETERS_ARB = 512 、 MAX_PROGRAM_ALU_INSTRUCTIONS_ARB = 1447だったので一応収まりはするが、当時の最小値はそれぞれ 24 と 48 なので全然足りないということになる。

一緒に出てきたエラーである ddxddy は多分微分演算で、これは OpenGL ES 2.0の規格でも OES_standard_derivatives 拡張に逃がされている。(こちらは、現在はほぼ100%の実装がサポートしている https://webglstats.com/webgl/extension/OES_standard_derivatives )。

頂点テクスチャフェッチ(VTF)

頂点シェーダのコンパイルでは頂点テクスチャフェッチ処理が失敗する。この機能はDirectX9世代では当時のATIも実装していなかったので、それより微妙に古いARB Assemblyではできなくても不思議ではない。

$ cgc -ogles -profile arbvp1 28.vert
28.vert
28.vert(47) : error C3004: function "vec4 texture2DLod(sampler2D, vec2, float);" not supported in this profile
28.vert(68) : error C3004: function "vec4 texture2DLod(sampler2D, vec2, float);" not supported in this profile
100 lines, 2 errors.

...が、これってそもそもOpenGL ES2でもオプションだし今でも Mali400 のような非サポートGPUは存在するので前提としては厳しいものがある気はする。

エラーになっている行はそれぞれ、

    u_xlat5.x = texture2DLod(_Outline_Sampler, u_xlat5.xy, 0.0).x;
...
    u_xlat3.xyz = texture2DLod(_BakedNormal, u_xlat3.xy, 0.0).xyz;

前者は アウトライン強弱調整用テクスチャ 。じゃあこの機能を使わないなら不要だな。

後者は ベイクドノーマル の実装とみられる。これも不要なはず。

※注意:この方式による頂点法線の調整は、バーテックスシェーダー側で行われますので、適用される頂点数にそのまま依存します。 つまり、ピクセルシェーダー側のように頂点法線間で補正するものではありませんので、注意してください。

たしかに頂点シェーダで行われているな。。

$ cgc -ogles -profile arbvp1 28a.vert
...
# 66 instructions, 6 R-regs

これらは割と常識的なサイズに収まった。

かんそう

ARB Assemblyに立ち返るというアイデアは我ながら悪くないと思うんだけど、現状処理系が cgc しか無い。KhronosのSPIR-V処理系( https://github.com/KhronosGroup/glslang )はそもそもWebGL1のシェーダを処理できず、GLES 3.0のシェーダに変換する必要がある。更に(当然だけど)SPIR-VからARB assemblyを出力するバックエンドも無いのでその辺は自作する必要がある。なるべくならココも作りたくないんだけど良い代替がない。

Spector.jsは便利だけど、いちいちキャプチャを取るのは面倒なのでwebgl-worker( https://github.com/kripken/webgl-worker )みたいなAPI proxyを用意して実タイトルを遊んでみたりconformanceを通しながら調整した方が良い気はする。当のBabylon.jsは自前のnative実行環境としてWebGLではなく bgfx を選択している( https://github.com/BabylonJS/BabylonNative )のはちょっと面白い。

WebGLやWebGPUしか実行できない "手抜きGPU" コアは割と需要は有る気はしている。WebGLやWebGPU自体が、各社のGPU実装やAPIのセキュアな最大公約数として成立するようにデザインされているのでKhronosの仕様ベース仕様 -- OpenGL ES や Vulkan -- を完全に実装するよりも多分コンパクトになるし、同じプログラムが他ハードウェアでも同様に動作するというのは良い特徴になるのではないだろうか。

例えばGoogleは(パフォーマンスAPIである)Vulkanに邁進していて( https://xdc2019.x.org/event/5/contributions/576/attachments/441/695/SwiftShader_Lightning_Talk.pdf )、次のDirectXが出てきたらどうすんだろうという気持ちになる。実際にはVulkanはWebGPUにはなれなかったように、抽象に耐えられなくなるポイントがどこかで来るかもしれない。

大昔のIntel内蔵GPU (GMA900) がそうだったように、ピクセルシェーダだけをハードウェア実装し、頂点シェーダはCPUに残すような実装は現代的に取り得るだろうか。ピクセルシェーダは精度制約が緩く、かつ、固定機能(ステンシルやZ等)に近いところに置く必要があるのでどうやってもハードウェア実装のメリットには勝てないと思うが、頂点シェーダのコードは、(少くともゲームやWebでは)あまり活用されない傾向にある。