Viewport Array を用いた Cascaded Shadow Mapping

ViewportArrayを用いたアプリケーションの例として、CascadedShadowMappingを見てみたいと思います。

Viewport Array

ViewportArrayとは、その名のとおりViewportを複数定義して、GeometryShaderよりgl_ViewportIndexを指定することにより、該当のPrimitiveが、どのViewport変換を適用されるかを決めることが出来る機能です。
この機能に相当するARB拡張は、GL_ARB_viewport_arrayで、OpenGL4.1で標準機能となっています。
また、ほとんどの場合これと合わせてgl_Layerの値を指定し、該当PrimitiveのPixelを出力するLayer(RenderTargetのIndex)を指定します。

関連する機能拡張

上記のとおり、LayerとViewportIndexを選択するためにはGeometryShaderが必要となり、複数のLayerに同時にレンダリングを行う場合には、GS内で複数のプリミティブをEmitする必要があり、これは性能面であまり好ましいことではありません。これを改善するために作られたいくつかのExtensionがあります。
いずれもGeometryShaderがボトルネックになるのを防ぐためのもので、実際のアプリケーションでは、ViewportArrayはこれらの機能と同時に使用することをある程度前提として考えてよいと思います。


  • GL_NV_geometry_shader_passthrough / GL_NV_viewport_array2
    GeometryShader内で、プリミティブをEmitすることなく、選択した複数のViewpot/Layerにプリミティブを出力する。

  • GL_AMD_vertex_shader_layer / GL_AMD_vertex_shader_viewport_index
    VertexShader内で、gl_Layer/gl_ViewportIndexを指定できる。

Cascaded Shadow Mapping

通常のCSMでは、ShadowMapの各Layerごとに個別の座標変換マトリクスを用いてレンダリングします。これに対し、ViewportArrayを用いるCSMの場合は、各Layerごとに変更できるのはViewport変換のみなので、この点が大きく異なります。使用する座標変換マトリクスはひとつです。CSMの全Layerが内包されるFrustumに対する変換マトリクスを使用します。
このマトリクスで描画されるFrustumは、NormalizedDeviceCoordinates(NDC)の範囲[XY:-1 ~ +1]に変換されます。CSMの各Layerでレンダリングするのは、このNDCの一部の領域なので、その範囲がWindow座標系でRenderTargetのサイズに一致するようにViewportのパラメーターを計算し設定します。
GeometryShader内では、カメラからの距離などに応じて出力するCSMのLayerを決定し、gl_Layerとgl_ViewportIndexを適切に設定してPrimitiveを出力します。

Viewportの計算

Viewportのパラメーターはx,y,w,hの4つで、通常のレンダリングで指定するViewportは、Window座標系の一部もしくは全部のの矩形領域(x, y)-(x+w, y+h)に、NDC[XY: +1~-1]が投影されるようになっています。
したがって、Viewport変換は基本的に以下の計算となっています。

Xwindow = Xndc * (w*0.5) + (x + (w*0.5));
Ywindow = Yndc * (h*0.5) + (y + (h*0.5));

これをScaleとOffsetによる一次変換と考えるなら、(以後Y軸は省略)以下のように考えることができると思います。

Scale = (w*0.5)
Offset = (x+(w*0.5))
x = Offset - Scale
w = Scale * 2

この変換を利用して、NDCの一部の領域が、RenderTargetに対応するWindow座標系に変換されるように設定します。レンダリングしたい矩形領域のNDC座標系におけるXの範囲を(x1, w1)とし、RenderTargetの解像度をresとするならば、NDC:(x1, w1)領域を Window:(0 ~ res)に変換するためのScaleとOffsetは、

Scale = res / w1
Offset = - (x1 * Scale)

となるはずで、これをglViewportの引数に変換すれば、

x = -(x1 + 1) *  res / w1
w = 2  * res / w1

となるはずです。

GL_MAX_VIEWPORT_DIMSとGL_VIEWPORT_BOUNDS_RANGE

上記の計算で、NDCの一部の領域をRenderTarget全体にレンダリングすることが出来るはずですが、制限事項があります。
GL_MAX_VIEWPORT_DIMSとGL_VIEWPORT_BOUNDS_RANGEは、どちらもGPUごとに異なる可能性のある定数で、glGet*()で取得することが出来ます。
GL_MAX_VIEWPORT_DIMSは、設定できるViewportの最大の幅、高さとなっており、viewportのwおよびhがこの値を超えている場合は、GLの仕様上ではClampされることになっています。
GL_VIEWPORT_BOUNDS_RANGEは、設定できるViewportの位置の範囲となっており、viewportのxおよびyがこの値の範囲を超えている場合は、GLの仕様上ではClampされることになっています。
これらの値は、最近のGPUの場合は、+-16384 ~ +-32768程度となっています。ShadowMapの大きさはせいぜい4K程度なので、通常のレンダリングではこの仕様が問題になることはありませんが、NDCの一部をWindow座標系に変換する場合は、この仕様に抵触する場合があります。上記ViewportArrayを用いたCSMの例で考えると、CSM全体のFrustumの、ごく一部を高解像度のShadowMapにレンダリングしようとすると、上記の制限に抵触する場合があります。言い方を変えれば、CSM全体のFrustumを、GL_MAX_VIEWPORT_DIMSの解像度のShadowMapにレンダリングした場合を超える解像度では、OpenGLの仕様上ではレンダリングできないことになっています。

Viewportを指定するAPI

void glViewportArrayv( GLuint first, GLsizei count, const GLfloat *v );
void glViewportIndexedf( GLuint index, GLfloat x, GLfloat y,GLfloat w, GLfloat h );
void glViewportIndexedfv( GLuint index, const GLfloat *v );
void glViewport( GLint x, GLint y, GLsizei w, GLsizei h );

glViewport()のみ整数型でViewportを指定します。上記のようなViewport変換を行う場合はfloat形で値を設定することが必要になるので、たとえ1つのViewportしか使わない場合でも、glViewportIndexf()を使う必要があります。