XMLで簡単にシェーダー芸(レイマーチング)が出来るフレームワークを作った
初めに
こんにちは、避雷です。2020年ももう終わりですね。
今年はずっと未踏事業を通してフロントエンドやGLSLの言語的拡張をやったりしていた気がします。成長出来ているのかはちょっと不明なんですけど、なんも考えなくても軽いウェブアプリを作れるようになったり、firebase大好きになったり、ロジック部分についてはちゃんとテストを書くようになったりしました。
今年は特にアドベントカレンダーなどに参加しているわけではないのですが、この時期になるとやはり記事が書きたくなったりします。今回は僕が作ったレイマーチング制作支援ツールについて紹介したいと思います。
シェーダー芸とは
webGLで用いられているGLSLやunityのShaderLabで用いられているHLSLを用いて、何らかのアートを制作する行為のことを指します。
本来は物体の質感(皮のツルツル感や岩のゴツゴツ感など)を表現するためのシェーダー・マテリアルで、立体形状を作ってしまったり、パーティクルなんかを表現したりしてしまいます。
(例 ↓ https://www.shadertoy.com/view/WtBSDz 拙作です)
ShaderToyなんかでは恐ろしい作品が日々投稿されています。魔境です。
レイマーチングとは
シェーダー芸の中でも主流な技術の一つが、レイマーチングです。上の作例もレイマーチングで作っています。
レイマーチングを簡単に説明すると、光を無限に直進する光線とみなし、目(カメラ)に入ってくる光線を逆向きにたどることでその光線がどんな特性(強さ、色など)を持っているかを調べ、それをキャンバスに書き込む技法です。
シェーダー芸の文脈では物体表面からの距離の陰関数表現
レイマーチングについてより深く知りたいならこちらの記事などが非常に分かりやすくおススメです。
距離関数とは
先ほど登場した距離関数について簡単に説明しようと思います。距離関数とはある物体表面からの距離を表す関数で、3次元の座標(ベクトル)を関数に渡すことで、その空間に存在する物体のうち最も近い表面までの距離を示します。この関数の返り値が
レイマーチングで用いられるのは「符号付」距離関数で、通常の「距離」と違ってマイナスの値を取る「距離」のアイデアを採用しています。通常の「距離」だけでは物体表面から物体内側に離れているのか、物体外側に離れているのか(つまりその点が物体にめり込んでいるのか、そうではないのか)が分かりません。そこで符号付距離関数では「物体にめり込む側の距離がマイナス」、「物体に離れる側の距離がプラス」として定義することで、物体の内外を定めることが出来ます。
上の図は距離関数(ただし2次元)を説明するものです。点線が物体表面からの距離が一定の等高線となっています。円や四角の内側に注目してもらえると、物体の内側では距離がマイナスになっていることが分かると思います。
めんどいよね/難しいよね
この距離関数ですがある程度の幾何学の知識を要求します。例えば原点中心半径
$$
f(\vec{x}) = ||\vec{x}|| - r
$$
で表現されます。これをコードで示すと
float sdSphere( vec3 p, float r)
{
return length(p)-r;
}
他にも、立方体のコードを表現するなら
float sdBox( vec3 p, vec3 b )
{
vec3 q = abs(p) - b;
return length(max(q,0.0)) + min(max(q.x,max(q.y,q.z)),0.0);
}
のようなコードになります。
Inigo Quilez氏筆頭に多くの先人によってプリミティブな立体についての距離関数がまとめられていますが、その意味を理解するのはなかなか難しく、慣れが必要になります。
Shader芸と呼ばれるものはコーディングとその出力結果が密に結びついており、コードの変化がグラフィックで派手に表示されるので(俗にいう「映え」)大衆ウケしそうだなと思っているのですが、いかんせんこの数学的なハードルのせいでレイマーチング(並びにシェーダー芸)に手を出しづらいという問題があります。
※幾何学的なアレコレについては詳しい情報については拙著ですが下記を参照してください。
レイマーチングで使われるSDFを解釈する~Part1~
レイマーチングで使われるSDFを解釈する~Part2~
レイマーチングで使われるSDFを解釈する~Part3~
レイマーチングで使われるSDFを解釈する~Part4~
こんなものをつくりました
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">HTMLっぽいコード(左上)を書くとレイマーチング用のglslコード(左下)を良い感じに自動生成してくれるシステムを作っています! 数学の知識とかあんまりなくても簡単にこんな感じのシェーダー芸が出来ます! 楽しい! <a href="https://t.co/5Rw53AQUIK">pic.twitter.com/5Rw53AQUIK</a></p>— 避雷 (@lucknknock) <a href="https://twitter.com/lucknknock/status/1311990561943941120?ref_src=twsrc^tfw">October 2, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
結構伸びました。やはり人類には普遍的にシェーダー書きたい欲があるんじゃないでしょうか?
この「Dynamis」はXMLでレイマーチングが出来るフレームワークです。XMLからGLSLコードを生成するシステムをtypescriptで実装しています。
ウェブで動くデモはこちら
Dynamis -だれでもシェーダー芸が出来るツール-
Dynamisはシェーダー芸、ライブコーディングの楽しさに気づいてもらえるまでの補助輪としての役割を果たすXML風シェーダー芸支援ツールです。
Dynamisでは立体形状の距離関数が分からなくても名詞や動詞をタグとして用いることで直感的にレイマーチングの距離関数を設計することが出来ます。
renderタグが一番外側のタグとして必須で、その中に距離関数の形状を示すタグを書き込むことで、対応するGLSLのコードを吐き出します。
例 1)
<render>
<sphere/>
</render>
この例では半径
precision highp float;
uniform vec3 resolution;
uniform float time;
float map(vec3 P_AAAA){
float d_aaaa = 10000000.0;
d_aaaa = min(d_aaaa,length(P_AAAA) - 0.5);
return d_aaaa;
}
vec3 getNormal(vec3 p)
{
float d = map(p); // Distance
vec2 e = vec2(.01,0); // Epsilon
vec3 n = d - vec3(
map(p-e.xyy),
map(p-e.yxy),
map(p-e.yyx));
return normalize(n);
}
vec4 trace(vec3 c,vec3 r){
float t = 0.0;
for(int i = 0; i < 128; i++){
vec3 p = c + r * t;
t += map(p) * (0.5);
}
vec4 q = vec4(getNormal(c + r * t),t);
return q;
}
void main() {
vec2 uv = (gl_FragCoord.xy * 2.0 - resolution.xy) / min(resolution.x, resolution.y);
vec3 c = vec3(0.0,0.0,-1.5);
vec3 r = normalize(vec3(uv,1.5));
vec4 t = trace(c,r);
float d = t.w;
vec3 n = t.xyz;
float v = 1.0/(1.0 + t.w * t.w * 0.2);
gl_FragColor = vec4(vec3(1.0 - dot(n,r) * dot(n,r))/(1.0 + d * d * 0.1), 1.0);
gl_FragColor.x = gl_FragColor.x;
gl_FragColor.y = gl_FragColor.y;
gl_FragColor.z = gl_FragColor.z;
gl_FragColor.w = 1.0;
}
map関数以外はほぼテンプレートで、map関数がシーン中の立体形状を表す距離関数となっています。先述の球体の距離関数と同じものが書かれていることが分かると思います。
例 2)
<render>
<twist axis="y" weight="4.0">
<donut axis="z"/>
<sphere radius="0.25"/>
</twist>
</render>
この例では球体とトーラスを作成した上でY軸を中心に捻っています。
例 3)
<render colX="n.r">
<fract x="2.0" y="1.5" z="2.0">
<twist axis="y" weight="4.0">
<donut axis="z"/>
<sphere radius="0.25"/>
</twist>
</fract>
</render>
render
タグにcolX
やcolY
パラメータを書き込むことでカラーリングをすることもできます。
この例では球体とトーラスを作成した上でY軸を中心に捻っています。
例 4)
<render>
<rot axis="y" angle="time * 1.0">
<box/>
</rot>
</render>
この例では時間に合わせて回転する立方体を生成しています。
このようにtwist
やdonut
と言った直感的な単語を用いることでレイマーチングで遊ぶことが出来ます。
使用例
yarn add https://github.com/Hirai0827/Dynamis
などでインポートすると自動でtsのトランスパイルが走って型情報とjsが吐き出されるので、それを利用してお好きな環境でXMLレイマーチングをすることが出来ます。
XMLで記述されたsrc:string
をstatic関数DynamisCompiler.Compile
に渡すことでGLSLコードを生成してくれます。Compile
の第二引数にUniformsについての情報を与えれば自動的にコードの頭の部分にUniformの定義をしてくれるので、webGL側でUniformに値を流すことで好きなUniformを利用することも出来ます。
const src = "<render><sphere></render>";
const res = DynamisCompiler.Compile(src);//コンパイルの実行
const res = DynamisCompiler.Compile(src,{uniforms:[{name:"hoge",type:"float"}]});//Uniformをセットできる(コンパイル時にコード頭の部分に定義が追加される)
const generatedGLSL = res.data;
実装
詳しい実装はgithubを見てもらうとして、XMLからGLSLを生成する過程について書いてみようと思います。
全体の流れ
DynamisCompiler.Compile
の中身を覗いて全体の流れを把握してみます。
export const Compile : (src:string,option?:DynamisCompileOption) => CompileResult =(src:string,option?:DynamisCompileOption) => {
let state:CompileState = "failed";
let errorMessage:ErrorMessage = "";
if(!DynamisXMLValidatior.Validate(src)){//山括弧列が正しいかどうか確認
console.error("Validation Failed");
return {
state:"failed",
errorType:"ParseError",
errorMessage:"Bracket array is not correct.may be some lack of \"<\" or \">\". ",
data:"error"
} as CompileResult;
}
if(!DynamisXMLValidatior.ValidateTagOnly(src)){//タグ以外のオブジェクトが含まれていないかどうかチェック
console.error("Validation Failed");
return {
state:"failed",
errorType:"ParseError",
errorMessage:"Dynamis Only Accept Tags. remove characters which are not in tag",
data:"error"
} as CompileResult;
}
const dynamisParser = new DynamisParser(src);//パーサの生成
const res = dynamisParser.Parse();//パースの実行
let ast:DynamisAST;
try {
ast = DynamisASTGenerator.Generate(res);//(ASTではないけど)構造木の生成
}catch (e) {
if(e.message == "Syntax Error"){//文法エラー(開始タグと終了タグがおかしいとか)
return {
state:"failed",
errorType:"ParseError",
errorMessage:errorMessage,
data:"error"
} as CompileResult;
}else{//未定義のタグが入っているエラー
return {
state:"failed",
errorType:"UnexpectedNodeError",
errorMessage:errorMessage,
data:"error"
} as CompileResult;
}
}
ast.props = {posId:0,distId:0};
ast.allocateProps({posId:0,distId:0});//座標系/距離関数の割り当て
let data = "";
data = ast.generateCode(option);//GLSLコードの生成
return {
state:"success",
errorType:null,
errorMessage:errorMessage,
data:data,
ast:ast
} as CompileResult;
};
XMLパース
今回は正規表現の勉強もしたかったのでXMLパーサを自作しています。単純な正規表現のみだとXMLの正しさ(正規表現では正しい括弧列かどうかなどを判定できない)を判定できないので正規表現によるタグ認識に加えてスタック構造にタグを突っ込んでいくことでパースを実装しています。Dynamisはライブコーディング中にリアルタイムにコンパイルできることを目指しているので、山括弧列などが正しいかどうかチェックして、明らかにコンパイルが通らない場合はそこでパースを打ち切るようにしています。パースの結果は構造木として返されます。
構造木
XMLからレイマーチングの構造を示す抽象的な木構造を生成します。といってもXMLの場合XML自体が木構造なのでパースした時点でこの工程はほぼ完了しています。この構造木ではASTほど細かい分割はしておらず(そこまでやる意味は無い/XMLだとちょっと難しい)、XMLのタグ単位で構造木を生成しています。
構造木からGLSLへのトランスパイル
構造木からGLSLでの距離関数に変換します。GLSLでの距離関数では上から下に実行されるので、木構造からそれに一本のコードに変換してあげる必要があります。
基本的にはトポロジカルソートによってある関数に必要な関数や変数が常に定義済みであるように配置していくのですが、木構造のトポロジカルソートにはテクニックもクソもない(DFSなりなんなりすれば勝手にソートされる)ので、この辺は良い感じに展開できます(ノードベースとかだとこの辺をしっかりしなきゃいけない)。
距離関数を生成する際には、タグで表現された立体形状を定義するのにどの座標系を使うのかを意識する必要があります。
Dynamisではオブジェクトの変形タグ(平行移動translate
や回転rot
、捻りtwist
)などが子を持つことが出来るタグで、実際に立体を指定するタグは子を持つことを許されていません(子を作っても無視されます)。
変形タグによって子ノードを作成することは、新しい座標系を作ってそれで距離関数を実装することに相当するので、変形タグを作る度に新しい座標系を定義しています。
変形ノードを通るとき、それより下の子ノードではその変形ノードで生成した座標系を使って距離関数を生成します。変形ノードの子ノードの距離関数を全て処理した後は、変形ノードの親ノードの座標系を戻して次の処理に移ることで、適切な座標系を用いて距離関数を合成していきます。
並列するノードは全て加算合成されます。例えば球体を表すノードと立方体を表すノードが同じ階層に並んでいる場合は、球体と立方体の両方が画面に出力されます。複数の距離関数の加算合成は二つの引数のうち小さい方の値を返すmin
関数によって実装されます(これは距離関数全般において言えることです)。
<render>
<twist axis="y" weight="4.0">
<donut axis="z"/>
<sphere radius="0.25"/>
</twist>
</render>
float map(vec3 P_AAAA){
float d_aaaa = 10000000.0;//ベースとなる距離関数の初期化
vec3 P_BAAA = P_AAAA;//ひねりを加えるための新しい座標系の作成
P_BAAA.zx = P_AAAA.zx * mat2(cos(P_AAAA.y * 4.0),-sin(P_AAAA.y * 4.0),sin(P_AAAA.y * 4.0),cos(P_AAAA.y * 4.0));//新しく作った座標系をひねる
d_aaaa = min(d_aaaa,length(vec2(length(P_BAAA.xy)-(0.5),P_BAAA.z)) - (0.1));//ドーナツ形状の距離関数を生成、マージ
d_aaaa = min(d_aaaa,length(P_BAAA) - 0.25);//球体の距離関数を生成、マージ
return d_aaaa;//距離関数を返す
}
別の例も見てみましょう
<render>
<fract x="2.0" y="2.0" z="2.0">
<twist axis="y" weight="4.0">
<donut axis="z"/>
<sphere radius="0.25"/>
</twist>
</fract>
<minus>
<pillar axis="z" radius="0.125"/>
</minus>
</render>
float map(vec3 P_AAAA){
float d_aaaa = 10000000.0;//ベースとなる距離関数の初期化
vec3 P_BAAA = P_AAAA;//新しい座標系の用意
P_BAAA.x = (fract(P_AAAA.x/(2.0) + 0.5) - 0.5) * (2.0);//用意した座標系を繰り返す(x軸)
P_BAAA.y = (fract(P_AAAA.y/(2.0) + 0.5) - 0.5) * (2.0);//用意した座標系を繰り返す(y軸)
P_BAAA.z = (fract(P_AAAA.z/(2.0) + 0.5) - 0.5) * (2.0);//用意した座標系を繰り返す(z軸)
vec3 P_CAAA = P_BAAA;//新しい座標系から更に新しい座標系を作る
P_CAAA.zx = P_BAAA.zx * mat2(cos(P_BAAA.y * 4.0),-sin(P_BAAA.y * 4.0),sin(P_BAAA.y * 4.0),cos(P_BAAA.y * 4.0));//座標系をひねる
d_aaaa = min(d_aaaa,length(vec2(length(P_CAAA.xy)-(0.5),P_CAAA.z)) - (0.1));//ひねった座標系を利用してドーナツの距離関数を追加
d_aaaa = min(d_aaaa,length(P_CAAA) - 0.25);//ひねった座標系を利用してドーナツの距離関数を追加
float d_baaa = 100000.0;//くり抜き用の新しい距離関数の用意
d_baaa = min(d_baaa,length(P_AAAA.xy) - 0.125);//くり抜き用の距離関数で円柱の距離関数を追加
d_aaaa = max(d_aaaa,-d_baaa);//元の距離関数にくり抜き用の距離関数を追加(くり抜きの実行)
return d_aaaa;//距離関数を返す
}
子ノードへ移る度に新しい座標系を生成していることが分かると思います。
変数の割り当て(命名)の実装が少し難しかった(ノードを跨いで「この変数は割り当て済みか?」という状態を持たなきゃいけないので)のですが、コード生成時に変数の利用状態を表すstateを持つことで何とか処理しています。
ノードに対応するコードの生成
それぞれのタグに相当するコードは、ある程度原型を用意した上でパラメータや変数名を動的に決定しています。
例えば球体を生成する<sphere/>
タグの実装は以下のようになっています。
generateCode = () => {
const posName = DynamisNameProvider.GetPosValName(this.props.posId);//利用する座標系(の変数名)を取得
const distName = DynamisNameProvider.GetDistValName(this.props.distId);//利用する距離関数(の変数名)を取得
const rad = this.params.Get("radius","0.5");//タグのパラメータを取得
const offX = this.params.Get("offX","0.0");//タグのパラメータを取得
const offY = this.params.Get("offY","0.0");//タグのパラメータを取得
const offZ = this.params.Get("offZ","0.0");//タグのパラメータを取得
let str:string = `${distName} = min(${distName},length(${posName} - vec3(${offX},${offY},${offZ})) - ${rad});\n`;//それぞれをstringとして代入したコードをstringとして合成、組み込む。
return str;
};
コードを読んでわかるように、タグに書き込んだパラメータは文字列として登録されるので、タグのパラメータは定数だけではなく例えばsin(time)
と言った数式を入力することもできます。
テスト
メタプログラミング系のコードなので割と丁寧に(当社比)テストを書いています。テスト用のパッケージとしてJestを採用しています。特に苦労しなくてもテストを書けるので重宝しています。また、GithubAcitonsでPRを出す度にテストが走るようになっています。前年までゲーム系のプログラミングばかりしていたのでテストを真面目に書き始めたのはつい最近なのですが、コードの正しさをある程度自動的に保証してくれるテストの存在は(特にこういったメタプログラミング系では)大きな安心感につながるなァと切に感じています。
パーステスト
パースの成否に関するテストです。タグが破綻しているときなどに適切に弾いてくれるように実装します。
//開始タグのみ
let res = DynamisCompiler.Compile("<render>");
expect(res.state).toBe("failed");
//タグ不一致
res = DynamisCompiler.Compile("<render></render1>");
expect(res.state).toBe("failed");
//OK
res = DynamisCompiler.Compile("<render> </render>");
expect(res.state).toBe("success");
コンパイルテスト
パース→トランスパイル後のGLSLコードについても、webGLでのコンパイルが通ることを保証したかったのでwebGLによるコンパイルテストを書いています。テストコード内で気軽にwebGLContextを生成するためにglのheadlessGLを利用しています(glってパッケージ名ググラビリティが低くて辛いですね)
const CompileForTest:(src:string) => CompileResult = (src) => {
const headlessGl = require("gl");
const gl = headlessGl(200, 200, { preserveDrawingBuffer: true });
//console.log(canvas);
if(gl){
let tmp_s = gl.createShader(gl.FRAGMENT_SHADER);
if(tmp_s){
gl.shaderSource(tmp_s, src);
gl.compileShader(tmp_s);
var status = gl.getShaderInfoLog(tmp_s);
if(status?.length == 0){
return "success";
}else{
console.error(status);
return "failed"
}
}else{
return "no_shader"
}
}else{
return "no_context";
}
};
let res = DynamisCompiler.Compile("<box/>",{uniforms:[{name:"hoge",type:"float"}]});
expect(CompileForTest(res.data)).toBe("failed");
//最小構成
res = DynamisCompiler.Compile("<render></render>");
expect(CompileForTest(res.data)).toBe("success");
//最小構成(変なUniform追加)
res = DynamisCompiler.Compile("<render></render>",{uniforms:[{name:"hoge",type:"float"}]});
パース時間テスト
一部のパースに失敗するソースコードに対して正規表現が暴発して許容できない時間がかかっている例が幾つかあったので、それらに対してパース実行時間テストを書いています。個人的には線形時間というか単純な走査で解ける(for文一個とかで書ける)問題については、無理に正規表現を使わずに愚直な実装をした方が良いときもある気がします。
let code = `<render camZ="time" camX="0.5" camY="0.5">
<fract>
<box x="0.25" y="0.25" z="0.25"/>
<abs axis="xyz">
<translate x="0.1" y="0.1">
<box x="0.025"z="1.0"y="0.025"/>
</translate>
</abs>
</fract>
<pillar a/>
</render>
`;
let startTime = performance.now();
let res = DynamisCompiler.Compile(code);
console.log(DynamisASTVisualizer.Visualize(res.ast as DynamisAST));
let endTime = performance.now();
expect(res.state).toBe("success");
expect(endTime - startTime).toBeLessThan(1000);
作例
このDynamisは、もとのGLSLに比べると表現力は劣るものの、非常に簡単かつ良い感じにシェーダー芸感のあるものを生成することが出来ます。
例えばこのようなものを作ることが出来ます。
<render camY="0.5" camX="0.1" colY="n.z/(1.0+d*d*0.1)">
<rot axis="y" angle="time * 0.5">
<rot axis="x" angle="time * 0.5">
<translate z="time">
<fract x="1.5" y="1.5">
<pmod axis="z">
<box x="0.25" y="0.25" z="0.5"/>
<box x="0.125" y="0.25" z="0.9"/>
<box x="0.5" y="0.25" z="0.125"/>
<box x="1.25" y="0.025" z="0.025"/>
<donut axis="z" radius="0.5" width="0.0125"/>
<abs axis="z">
<translate z="0.25">
<donut axis="z" radius="0.3" width="0.0125"/>
</translate>
</abs>
</pmod>
</fract>
</translate>
</rot>
</rot>
</render>
<render camZ="0.0">
<rot axis="x" angle="time * 0.0">
<rot axis="y" angle="time * 0.5">
<translate z="time" y ="0.0">
<twist axis="z" weight="0.125">
<pmod axis="z" div="6.0 + 2.0 * sin(time)">
<fract x="1.5" y="1.5" z="0.5">
<box x="0.25" y="0.25" z="0.25"/>
<box x="0.25" y="1.25" z="0.25"/>
<box x="0.125" y="100.0" z="0.125"/>
<box x="0.125/2.0" y="0.125/2.0" z="1.0"/>
<donut axis="z" radius="0.5" width="0.0125"/>
<donut axis="y" radius="0.15" width="0.0125" offX="0.5"/>
</fract>
</pmod>
</twist>
<minus>
<pillar axis="z" radius="0.25"/>
</minus>
</translate>
</rot>
</rot>
</render>
さいごに
今回はXMLからGLSLを生成するツールを作ってみました。
今後の課題としてはGLSLコードの最適化/パーサの最適化などをやりたいなと思っています。
本プロジェクトで一番良かったのは、距離関数を表現する抽象的な構造を用意することが出来たことです。これを介することで様々な入力と様々な出力で距離関数を用いることが出来るようになりした。
今回はXML→GLSL変換を作成してみましたが、今後は構造木を介してノードベースシェーダーエディタやGLSL/HLSL同時コンパイルなどにも挑戦してみたいです。
宣伝1
1週間でシェーダー・アートを作るShader1weekCompoというゆるい企画をブタジエン(https://twitter.com/butadiene121 )氏と共同で主催しています。シェーダーを使ったアウトプットの良い機会としてご利用いただければ嬉しいです。シェーダーを使っていればどんな作品でもよいので、お気軽に作品を投稿してみてください。詳しくはこちらをご覧ください!
宣伝2
現在身未踏事業で制作中のシェーダー・ライブコーディング・アーカイブのプラットフォームである「LiCo」のβテストを行っております。Discordのサーバでコミュニケーションなどを取っておりますので、是非参加してみてください!
**参加はこちらから!
Discussion