今日は少しコアな内容の記事。Grimoire.jsでは、レンダラーの構造自身もタグだが、どのように成り立っているのか解説する。
最小限のコードとコードの補完
Grimoire.jsに最小限のgomlを書いて動作させるようにするには、以下のようなコードがあれば十分に動作が確認できる。
<goml width="fit" height="fit">
<scene>
<camera/>
<mesh geometry="cube" color="red"/>
</scene>
</goml>
上の結果の表示↓
しかし、実際には足りないコードを自動的に補間しているだけであり、実際には以下のようなコードになっている。
<goml width="fit" height="fit">
<!--補完されていた部分-->
<renderer camera="camera">
<render-scene/>
</renderer>
<!--ココマデ-->
<scene>
<camera/>
<mesh geometry="cube" color="red"/>
</scene>
</goml>
そのため、以上のコードをそのままgomlとして書いても先ほどと全く同じ動作をする。補完されない状態での最小限のコードといえば、上記が補完されない状態での最小限のコードだ。
コンポーネントとメッセージシステム
レンダラーの仕組みを解説する前に、少しだけコンポーネントの仕組みについて触れる必要がある。
ノードの仕組み
Grimoire.jsではあるノード(goml
やrenderer
など)は、それぞれが親子関係をなし(上記の例ではrenderer
はgoml
の子)、名前に結び付けられたコンポーネントの配列を持つものである。
例えば、mesh
を作ると、TransformComponent
、MeshRenderer
、MaterialContainer
の3つのコンポーネントが初期状態でくっついたノードが作成される。
Unityを弄ったことがある人がいれば、
Hierarchyビュー
の中で、Create
で選べるものがノードそのものであるという解釈が近いだろう。
例えば、UnityでもCube
を選択すればMeshRenderer
,MeshFilter
,Transform
が含まれる。これと同じだ。
コンポーネントとメッセージシステム
コンポーネントを具体的に、どう定義するかは他の日のAdventCalenderもしくは、公式サイトのチュートリアルを参考にしてほしいが、$update
メソッド、$mount
メソッド、$awake
メソッドなど場合に応じて呼び出される特殊なメソッドがあると記述してある。
この$
から始まるメソッドをメッセージハンドラーと呼称する。一般的にコンポーネントはこれらのメッセージハンドラーを実装することで構築するが、あらかじめ用意された種類のメッセージ(updateやawakeなど)以外にも、自分でもちろん新しい種類のメッセージを定義することができる
コンポーネント内では、this.node
によって、GomlNode
クラスを取得でき、このGomlNodeにはbroadcastMessage
メソッドと、sendMessage
メソッドが存在する。レンダラーはこの機能を利用して実装されているので軽く触れておく。
sendMessage
以下は、sendMessageの定義である。(Typescriptの定義をわかりやすくするため例示しているが、実際にはjavascriptからでも呼び出せる)
public sendMessage(messageName:string, arg:any):void;
例えば、this.node.sendMessage("ABC",123);
という呼び出しをコンポーネントの中で行なったとすると、そのコンポーネントの属するノードの中のコンポーネント全ての中で、$ABC
を持つコンポーネントの$ABC
を第一引数123として呼び出すことになる。
broadcastMessage
以下はbroadcastMessageの定義である
public broadcastMessage(range: number, messageName: string, args?: any): void;
public broadcastMessage(messageName: string, args?: any): void;
broadcastMessageは、sendMessageを再帰的にノードに施すためにある。
例えば、一番上の例で、sceneノード
に対してbroadcastMessage("ABC",123);
をしたとすると、scene
,mesh
,camera
ノードそれぞれの中のコンポーネントから$ABC
メソッドを引数123として呼び出しを再帰的に行う。
また、最初に数値を伴って呼び出す再帰の深さを定めることができる。例えば、goml
ノードに対して、broadcastMessage(1,"ABC",123);
を呼び出したとすれば、scene
とrenderer
に対してのみ呼び出しが行われる。
メインループ
他の多くのWebGLライブラリと同じように、Grimoire.jsも毎フレームごとにループをすることによって、画面を出力している。
このメインループを管理する役割を持つコンポーネントであるLoopManager
がgoml
ノードに含まれている。
renderViewportメッセージのブロードキャスト
このLoopManagerが各フレームごとに呼び出す関数の配列を保持しており、デフォルトの状態では以下のただ一つだけを持っている。
function loop(){
// ~~キャンバスのクリア処理~~
...
// レンダリング処理のbroadcastMessage
this.node.broadcastMessage(1,"renderViewport",{loopIndex:loopIndex});
}
このthis.node
はgoml
ノードそのものであるので**、自身の子の中のコンポーネントから$renderViewport
メソッドを呼び出すことになる。**
これによって、もしもrenderer
ノードが複数存在しても、それぞれ独立して処理が行われる。
renderer
一つでキャンバス内の特定の領域の描画を引き受ける。これによって、モデリングソフトなどで見受けられる4つに分割されている別の視点から見た画面なども実現できる。
renderメッセージのブロードキャスト
renderer
ノードに含まれるRendererComponent
は以下のような実装のrenderViewportメッセージハンドラを持つ。
public $renderViewport(args: { loopIndex: number }): void {
this.node.broadcastMessage("render",{
camera: this._camera,
viewport: this._viewportCache,
loopIndex: args.loopIndex
});
}
rendererノードは、クエリでcameraを指定する機能があったり、viewportを指定する機能があるので、これらの条件を踏まえて自身の子にbroadcastMessageをrender
メッセージハンドラに対して行う。
render-sceneノード
render-scene
ノードにはRenderSceneComponent
が含まれていて、render
メッセージを持つ。
このrenderメッセージによって、指定されたカメラのCameraComponent
に対して自身のノードの含まれるシーンノードからupdate
メッセージを呼び出すようにメソッドを呼び出す。
(このupdateこそが、各種サンプルで用いられている$updateそのものである)
このupdateが終わると、実際にrenderメソッドを、scene配下のMeshRenderer
全てに対して行うが、ここでは描画順序が関係あるため、SceneComponent
がソートしてからメッセージを介さずにrender
メソッドを呼び出している。
メインループのまとめ
以上のように少し複雑だが、まとめると以下のように各ループでは呼び出しが行われている。
1,LoopManagerが各フレームごとに自身の1階層下に$renderViewportメッセージを呼び出す
1-1,rendererに含まれるRendererComponentが自身以下に$rendererメッセージを送る
1-1-1,render-sceneに含まれるRenderSceneComponentが指定されたカメラのCameraComponentを探す
1-1-1-1,カメラが自身の含まれるsceneからupdateメッセージを呼び出す
1-1-1-2, カメラが自身の含まれるsceneに対して配下のMeshRendererのrenderメソッドを描画順に呼び出すようメソッドを呼び出す
rendererノードとその子要素
上記の説明をみれば、renderer
ノードの中にあるrender-scene
ノードはシーンの描画を行うことを示しているが、それは少し当たり前に見えるかもしれない。
しかし、renderer
ノードが行うのは何もシーンのレンダリングだけとは限らないのだ。実際、render-scene
以外のレンダリング処理を定義したタグも存在する。
例えば、2Dのシェーダーアート的なことをする場合の例を以下のように見てみよう。
render-quadノード
render-scene
以外のrender-XXX
系の実装としてrender-quad
ノードが含まれている。
これは、scene全体のメッシュを描画するrender-scene
ノードと異なり、ただ単一のquad(ただの板)を特定のマテリアルで描画できる。
これを用いれば、ただ単にシェーダだけで絵を出すようなものが簡単に実装できる。
例えば、Grimoire.jsのExampleページにリンクがある、以下のクローバーもすべてシェーダーで記述されていて、これを用いている。
実際にgomlファイルを見てみれば、以下のような記述がある。
<goml height="fit">
<import-material typeName="shader" src="index.sort"/>
<renderer>
<render-quad material="new(shader)"/>
</renderer>
</goml>
上の表示に比べてみれば、驚くほどシンプルなコードだが、このgomlファイルのやっていることは、index.sort
を用いて四角形を画面いっぱいに描画することである。
rendererはただ単に、自分の子要素に対してrenderメッセージをブロードキャストしているだけであるから、render-quad
の下にrender-scene
を追加すればこれを背景要素として用いることができる。
あくまで、描画というのがrender-XXX
が行う、ビューポートに対しての処理というふうに抽象化されているのだ。普段、render-scene
が省略されていて、これが描画する対象のscene
ノードが必要だから普段scene
ノードが用いられるにすぎない。
故に、このサンプルの記述を以下のように記述すればシーンの内容をクローバーの絵の上に合成することが可能だ。
<goml height="fit">
<import-material typeName="shader" src="index.sort"/>
<renderer camera="camera">
<render-quad material="new(shader)"/>
<render-scene/>
</renderer>
<scene>
<camera position="0,0,10">
<camera.components>
<MouseCameraControl center="10"/>
</camera.components>
</camera>
<mesh geometry="cube" color="#FF000055"/>
</scene>
</goml>
バックバッファ
エフェクトを作成するときだけでなく、様々なケースでバックバッファへのレンダリングが必要になるケースがある。
例えば、鏡のエフェクトなど、別のカメラで撮った画像をテクスチャにして別のカメラでのレンダリング時に用いる場合だ。
この解説をするまえに実際の例のコードを見てみよう。
<goml height="fit">
<import-material typeName="shader" src="index.sort"/>
<renderer camera="camera">
<texture-buffer name="b1"/>
<render-quad material="new(shader)" out="b1"/>
<render-scene/>
</renderer>
<scene>
<camera position="0,0,10">
<camera.components>
<MouseCameraControl center="10"/>
</camera.components>
</camera>
<mesh geometry="cube" texture="backbuffer(b1)"/>
</scene>
</goml>
以上のように、クローバーを描画するシェーダーを用いて、先にテクスチャに書き込み、その内容を用いてcubeを描画した。
以下注目すべきコードの部分だけ解説する。
texture-bufferノード
renderer
ノードの配下に新たに加わったtexture-buffer
ノードはそのrenderer
と同じサイズ(設定により変更はできる)のテクスチャをバックバッファとして生成する。
その際、名前を必ずつける必要があり、そのrendererを用いてレンダリングする際にはbackbuffer(バッファの名前)
を用いてテクスチャ名のみで参照することができる。
render-quadのout属性
render-quadのout属性は描画先を意味する。通常ではdefault
になっており、default
の時は、キャンバスにそのまま描画する。これが初期値なので、普段render-scene
などを扱う上では考慮しなくて良い。
一方で、outにバッファ名を指定すればそのテクスチャに描画することができる。
そしてこれらによってテクスチャb1
に描画されたデータをテクスチャとしてcubeに渡して描画しているのである。
独自render-XXXの実装(render-pingpong)
ここで例のために特殊なレンダラーを実装してみる。
例えば、ある入力に対してあるシェーダーによる変換をN回繰り返した結果を出力するレンダラーを考える。
今回は、この独自で作成したrender-pingpongによって、クローバーの色と画像の方向を変化させることをN回繰り返して、色の平均を取ることを行なった。(本来、これをするなら元々のシェーダーを編集した方が高速だが。。。)
このために実際に作成したgomlは以下である。
<goml width="fit" height="fit">
<geometry name="quad" type="quad"/>
<import-material typeName="clover" src="index.sort"/>
<import-material typeName="pingpong" src="pingpong.sort"/>
<renderer>
<texture-buffer name="b1"/>
<texture-buffer name="p1"/>
<texture-buffer name="p2"/>
<render-quad material="new(clover)" out="b1"/>
<render-pingpong material="new(pingpong)" pingpongBuffer1="p1" pingpongBuffer2="p2" sourceBuffer="b1" iteration="4"/>
</renderer>
</goml>
今回、このサンプル用に作ったrender-pingpong
タグによって、cloverシェーダーによって描画された内容を4回繰り返す用に記述されている。
繰り返したシェーダーは以下のとおりで、色成分を置き換えて90度回転するコードだ。
@Pass
@NoDepth()
FS_PREC(mediump,float)
varying vec2 vUV;
#ifdef VS
attribute vec3 position;
attribute vec2 texCoord;
void main(){
gl_Position = vec4(position, 1.);
vUV = texCoord;
}
#endif
#ifdef FS
uniform sampler2D source;
void main() {
vec2 uv = vUV;
uv.y = 1. - uv.y;
vec2 uv2 = vec2(uv.y,1.-uv.x);
gl_FragColor.rgb = (texture2D(source,uv2).rgb + texture2D(source,uv).gbr)/2.;
gl_FragColor.a = 1.0;
}
#endif
これらの定義は少し長いが以下のように記述した。
const FrameBuffer = gr.lib.fundamental.Resource.FrameBuffer;
gr.registerComponent("RenderPingPong", {
attributes: {
out: {
defaultValue: "default",
converter: "String"
},
pingpongBuffer1: {
defaultValue: null,
converter: "String"
},
pingpongBuffer2: {
defaultValue: null,
converter: "String"
},
targetBuffer: {
defaultValue: "default",
converter: "String",
},
sourceBuffer: {
defaultValue: null,
converter: "String"
},
iteration: {
defaultValue: 2,
converter: "Number"
},
sourceName: {
defaultValue: "source",
converter: "String"
},
clearColor: {
defaultValue: "#0000",
converter: "Color4",
},
clearColorEnabled: {
defaultValue: true,
converter: "Boolean",
}
},
$awake: function() {
// あらかじめ変数にattributeは結び付けておく
this.getAttribute("targetBuffer").boundTo("_targetBuffer");
this.getAttribute("sourceName").boundTo("_sourceName");
this.getAttribute("clearColor").boundTo("_clearColor");
this.getAttribute("clearColorEnabled").boundTo("_clearColorEnabled");
this.getAttribute("iteration").boundTo("_iteration");
},
$mount: function() {
this._gl = this.companion.get("gl"); // companionは、gomlツリー全体でシェアするようなリソースを保存できる場所。
this._canvas = this.companion.get("canvasElement");
const gr = this.companion.get("GeometryRegistory");
this._geom = gr.getGeometry("quad");
this._materialContainer = this.node.getComponent("MaterialContainer");
},
$bufferUpdated: function(args) {
const out = this.getValue("out");
if (out !== "default") {
this._outFBO = new FrameBuffer(this._gl);
this._outFBO.update(args.buffers[out]);
this._fboSize = args.bufferSizes[out];
}
const pingpong1 = this.getValue("pingpongBuffer1");
if (!pingpong1) {
throw new Error("ping pong buffer 1 must be specified")
}
this._pingpong1FBO = new FrameBuffer(this._gl);
this._pingpong1FBO.update(args.buffers[pingpong1]);
this._pfboSize1 = args.bufferSizes[pingpong1];
const pingpong2 = this.getValue("pingpongBuffer2");
if (!pingpong2) {
throw new Error("ping pong buffer 2 must be specified");
}
this._pingpong2FBO = new FrameBuffer(this._gl);
this._pingpong2FBO.update(args.buffers[pingpong2]);
this._pfboSize2 = args.bufferSizes[pingpong2];
},
$render: function(args) {
if (!this._materialContainer.ready) {
return;
}
let currentSource = args.buffers[this.getValue("sourceBuffer")];
let nextSource;
let currentFBO;
for (let i = 0; i < this._iteration; i++) {
if (i === this._iteration - 1) {
// 最終レンダリングなので、outFBOを用いる
if (this._outFBO) {
this._outFBO.bind();
this._gl.viewport(0, 0, this._fboSize.width, this._fboSize.height);
currentFBO = this._outFBO;
} else { // デフォルトバッファを用いる場合
this._gl.bindFramebuffer(WebGLRenderingContext.FRAMEBUFFER, null);
this._gl.viewport(args.viewport.Left, this._canvas.height - args.viewport.Bottom, args.viewport.Width, args.viewport.Height);
}
} else {
if (i % 2 === 0) {
this._pingpong1FBO.bind();
this._gl.viewport(0, 0, this._pfboSize1.width, this._pfboSize1.height);
currentFBO = this._pingpong1FBO;
nextSource = args.buffers[this.getValue("pingpongBuffer1")];
} else {
this._pingpong2FBO.bind();
this._gl.viewport(0, 0, this._pfboSize2.width, this._pfboSize2.height);
currentFBO = this._pingpong2FBO;
nextSource = args.buffers[this.getValue("pingpongBuffer2")];
}
}
// clear buffer if needed
if (currentFBO && this._clearColorEnabled) {
this._gl.clearColor(this._clearColor.R, this._clearColor.G, this._clearColor.B, this._clearColor.A);
this._gl.clear(WebGLRenderingContext.COLOR_BUFFER_BIT);
}
// make rendering argument
const renderArgs = {
targetBuffer: this._targetBuffer,
geometry: this._geom,
attributeValues: {},
camera: null,
transform: null,
buffers: args.buffers,
viewport: args.viewport,
defaultTexture: this.companion.get("defaultTexture")
};
renderArgs.attributeValues = this._materialContainer.materialArgs;
// 指定されたsourceName変数に、一つ前のバッファで描画したテクスチャを差し込む
renderArgs.attributeValues[this._sourceName] = {get:()=>currentSource};
// do render
this._materialContainer.material.draw(renderArgs);
this._gl.flush();
currentSource = nextSource;
}
}
});
gr.registerNode("render-pingpong", ["MaterialContainer", "RenderPingPong"], {});
以下、各部分に分けて解説をしていく。
bufferUpdatedメッセージ
$bufferUpdated: function(args) {
const out = this.getValue("out");
if (out !== "default") {
this._outFBO = new FrameBuffer(this._gl);
this._outFBO.update(args.buffers[out]);
this._fboSize = args.bufferSizes[out];
}
const pingpong1 = this.getValue("pingpongBuffer1");
if (!pingpong1) {
throw new Error("ping pong buffer 1 must be specified")
}
this._pingpong1FBO = new FrameBuffer(this._gl);
this._pingpong1FBO.update(args.buffers[pingpong1]);
this._pfboSize1 = args.bufferSizes[pingpong1];
const pingpong2 = this.getValue("pingpongBuffer2");
if (!pingpong2) {
throw new Error("ping pong buffer 2 must be specified");
}
this._pingpong2FBO = new FrameBuffer(this._gl);
this._pingpong2FBO.update(args.buffers[pingpong2]);
this._pfboSize2 = args.bufferSizes[pingpong2];
}
このメッセージは新しいbuffer-textureが初期化された場合や、リサイズされた場合にrendererからよびだされる。今回は、outに指定されているテクスチャ、pingpongBuffer1のテクスチャ、pingpongBuffer2のテクスチャそれぞれに出力するためのフレームバッファを作成しているだけである。(本来は、resizeの時は、前に使っていたフレームバッファを削除する必要がある)
renderメッセージ
この部分のほとんどはrender-quadの内容をコピーしたものであるから、pingpong内で繰り返してfboを切り替えている部分を中心的に見る。
if (i === this._iteration - 1) {
// 最終レンダリングなので、outFBOを用いる
if (this._outFBO) {
this._outFBO.bind();
this._gl.viewport(0, 0, this._fboSize.width, this._fboSize.height);
currentFBO = this._outFBO;
} else { // デフォルトバッファを用いる場合
this._gl.bindFramebuffer(WebGLRenderingContext.FRAMEBUFFER, null);
this._gl.viewport(args.viewport.Left, this._canvas.height - args.viewport.Bottom, args.viewport.Width, args.viewport.Height);
}
} else {
if (i % 2 === 0) {
this._pingpong1FBO.bind();
this._gl.viewport(0, 0, this._pfboSize1.width, this._pfboSize1.height);
currentFBO = this._pingpong1FBO;
nextSource = args.buffers[this.getValue("pingpongBuffer1")];
} else {
this._pingpong2FBO.bind();
this._gl.viewport(0, 0, this._pfboSize2.width, this._pfboSize2.height);
currentFBO = this._pingpong2FBO;
nextSource = args.buffers[this.getValue("pingpongBuffer2")];
}
}
それぞれ、3種類のFBOを切り替えて、 nextSourceにテクスチャを代入しておき、次のイテレーションで用いる。
ノードの作成
gr.registerNode("render-pingpong", ["MaterialContainer", "RenderPingPong"], {});
materialを受け取る場合は、実はmaterial内のシェーダー変数が属性名に露出するなど、案外多くのコードを書かなければならない。そのため、あらかじめ定義されているMaterialContainer
コンポーネントを用いればmaterialを用いれる。
実際、RenderPingPong
コンポーネントもmount時にあらかじめ自身のノードからgetComponent("MaterialContainer")
を呼び出している。
このように複数描画するものを用いれば、例えばWebcamを用いて動体を消すようなエフェクトなども作成可能だ。(少しこのコンポーネントを修正する必要があるが)
まとめ
Grimoire.jsではレンダラーを含むすべてがノードとコンポーネントの記述法によって成り立っている。MaterialやGeometryについては今回は触れていないが、実際にはすべてがこの原則の上に成り立っている。
これにより、必要な箇所を最低限の必要知識で新しい機能を追加、利用、公開することが容易になる。
renderer
ノード内のrender-XXX
ノードにより、実際にrenderer
が対象のビューポートに対して何を行うかが定まる。これを組み合わせて使えば、ポストエフェクトや特殊なオフスクリーンレンダリングなど様々なことが実行可能だ。
一方で、これを新たに自分で作ることもできる。特殊な描画記法や最適化のためなど、なんであれ同じ方法でコンポーネントで記述でき、そのユーザーは同一の手法でそれを生かすことができる。