LoginSignup
2

More than 5 years have passed since last update.

Grimoire.jsのタグベースレンダラーの仕組み

Last updated at Posted at 2016-12-05

今日は少しコアな内容の記事。Grimoire.jsでは、レンダラーの構造自身もタグだが、どのように成り立っているのか解説する。

最小限のコードとコードの補完

Grimoire.jsに最小限のgomlを書いて動作させるようにするには、以下のようなコードがあれば十分に動作が確認できる。

index.goml
    <goml width="fit" height="fit">
        <scene>
            <camera/>
            <mesh geometry="cube" color="red"/>
        </scene>
    </goml>

上の結果の表示↓

しかし、実際には足りないコードを自動的に補間しているだけであり、実際には以下のようなコードになっている。

index.goml
    <goml width="fit" height="fit">
        <!--補完されていた部分-->
        <renderer camera="camera">
            <render-scene/>
        </renderer>
        <!--ココマデ-->
        <scene>
            <camera/>
            <mesh geometry="cube" color="red"/>
        </scene>
    </goml>

そのため、以上のコードをそのままgomlとして書いても先ほどと全く同じ動作をする。補完されない状態での最小限のコードといえば、上記が補完されない状態での最小限のコードだ。

コンポーネントとメッセージシステム

レンダラーの仕組みを解説する前に、少しだけコンポーネントの仕組みについて触れる必要がある。

ノードの仕組み

Grimoire.jsではあるノード(gomlrendererなど)は、それぞれが親子関係をなし(上記の例ではrenderergomlの子)、名前に結び付けられたコンポーネントの配列を持つものである。

例えば、meshを作ると、TransformComponentMeshRendererMaterialContainerの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);を呼び出したとすれば、scenerendererに対してのみ呼び出しが行われる。

メインループ

他の多くのWebGLライブラリと同じように、Grimoire.jsも毎フレームごとにループをすることによって、画面を出力している。
このメインループを管理する役割を持つコンポーネントであるLoopManagergomlノードに含まれている。

renderViewportメッセージのブロードキャスト

このLoopManagerが各フレームごとに呼び出す関数の配列を保持しており、デフォルトの状態では以下のただ一つだけを持っている。

function loop(){
   // ~~キャンバスのクリア処理~~
   ...
   // レンダリング処理のbroadcastMessage
   this.node.broadcastMessage(1,"renderViewport",{loopIndex:loopIndex});
}

このthis.nodegomlノードそのものであるので、自身の子の中のコンポーネントから$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ファイルを見てみれば、以下のような記述がある。

index.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ノードが用いられるにすぎない。

故に、このサンプルの記述を以下のように記述すればシーンの内容をクローバーの絵の上に合成することが可能だ。

index.goml
<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>

バックバッファ

エフェクトを作成するときだけでなく、様々なケースでバックバッファへのレンダリングが必要になるケースがある。
例えば、鏡のエフェクトなど、別のカメラで撮った画像をテクスチャにして別のカメラでのレンダリング時に用いる場合だ。

この解説をするまえに実際の例のコードを見てみよう。

index.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は以下である。

index.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

これらの定義は少し長いが以下のように記述した。

index.js

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を切り替えている部分を中心的に見る。

index.js
            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にテクスチャを代入しておき、次のイテレーションで用いる。

ノードの作成

index.js
gr.registerNode("render-pingpong", ["MaterialContainer", "RenderPingPong"], {});

materialを受け取る場合は、実はmaterial内のシェーダー変数が属性名に露出するなど、案外多くのコードを書かなければならない。そのため、あらかじめ定義されているMaterialContainerコンポーネントを用いればmaterialを用いれる。
実際、RenderPingPongコンポーネントもmount時にあらかじめ自身のノードからgetComponent("MaterialContainer")を呼び出している。


このように複数描画するものを用いれば、例えばWebcamを用いて動体を消すようなエフェクトなども作成可能だ。(少しこのコンポーネントを修正する必要があるが)

まとめ

Grimoire.jsではレンダラーを含むすべてがノードとコンポーネントの記述法によって成り立っている。MaterialやGeometryについては今回は触れていないが、実際にはすべてがこの原則の上に成り立っている。
これにより、必要な箇所を最低限の必要知識で新しい機能を追加、利用、公開することが容易になる。

rendererノード内のrender-XXXノードにより、実際にrendererが対象のビューポートに対して何を行うかが定まる。これを組み合わせて使えば、ポストエフェクトや特殊なオフスクリーンレンダリングなど様々なことが実行可能だ。
一方で、これを新たに自分で作ることもできる。特殊な描画記法や最適化のためなど、なんであれ同じ方法でコンポーネントで記述でき、そのユーザーは同一の手法でそれを生かすことができる。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2