読者です 読者をやめる 読者になる 読者になる

凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity で距離に応じたテッセレーションを行ってみた

Unity Shader

はじめに

テッセレーションを行うと、ポリゴンの分割数を増やしてより滑らかな曲面を作成できたり、分割された頂点をテクスチャを参照して直接盛り上げたり(ディスプレースメントマッピング)出来ます。ただ、分割数を増やすにはそれなりのコストがかかるので、分割数を増やすのは必要なときのみにしたいところです。その一つの方法として、距離に応じて分割数を変化させることを考えてみたいと思います。

今のところ Windows など一部のプラットフォーム限定ですが、Unity でテッセレーションを行いたい場合は以下のドキュメントを参考にすると簡単にできます。そして距離に応じたテッセレーションは組み込み関数として用意されていて、 UnityDistanceBasedTess() を呼ぶことで簡単に実現できます。

上記はサーフェスシェーダの例なので、本エントリでは、ドキュメントの内容の紹介と頂点 / フラグメントシェーダへの組み込みについて触れてみたいと思います。

サーフェスシェーダ

テッセレーションを行うには、ポリゴンをどのように分割するのかを決めるハルシェーダと、分割されたポリゴンをどのように動かすのかを記述するドメインシェーダが必要です。しかしながらサーフェスシェーダでは #pragma 文でテッセレーションを行う関数を指定すると、他は通常のサーフェスシェーダ(+ 頂点シェーダ)相当のシェーダを書くだけでテッセレーションを行うことができ、大半の処理を省略することができます。コードを見てみましょう。

Shader "SurfaceShader-Tessellation" 
{

Properties
{
    _Color("Color", color) = (1, 1, 1, 0)
    _MainTex("Base (RGB)", 2D) = "white" {}
    _DispTex("Disp Texture", 2D) = "gray" {}
    _NormalMap("Normalmap", 2D) = "bump" {}
    _SpecColor("Spec color", color) = (0.5, 0.5, 0.5, 0.5)
    _MinDist("Min Distance", Range(0.1, 50)) = 10
    _MaxDist("Max Distance", Range(0.1, 50)) = 25
    _TessFactor("Tessellation", Range(1, 50)) = 10
    _Displacement("Displacement", Range(0, 1.0)) = 0.3
}

SubShader
{

Tags { "RenderType" = "Opaque" }

CGPROGRAM

#pragma surface surf BlinnPhong addshadow fullforwardshadows vertex:disp tessellate:tessDistance nolightmap
#pragma target 5.0
#include "Tessellation.cginc"

float _TessFactor;
float _Displacement;
float _MinDist;
float _MaxDist;
sampler2D _DispTex;
sampler2D _MainTex;
sampler2D _NormalMap;
fixed4 _Color;

struct appdata 
{
    float4 vertex   : POSITION;
    float4 tangent  : TANGENT;
    float3 normal   : NORMAL;
    float2 texcoord : TEXCOORD0;
};

struct Input 
{
    float2 uv_MainTex;
};

float4 tessDistance(appdata v0, appdata v1, appdata v2) 
{
    return UnityDistanceBasedTess(v0.vertex, v1.vertex, v2.vertex, _MinDist, _MaxDist, _TessFactor);
}

void disp(inout appdata v)
{
    float d = tex2Dlod(_DispTex, float4(v.texcoord.xy,0,0)).r * _Displacement;
    v.vertex.xyz += v.normal * d;
}

void surf(Input IN, inout SurfaceOutput o) 
{
    half4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;
    o.Specular = 0.2;
    o.Gloss = 1.0;
    o.Normal = UnpackNormal(tex2D(_NormalMap, IN.uv_MainTex));
}

ENDCG

}

FallBack "Diffuse"

}

f:id:hecomi:20161128221556g:plain f:id:hecomi:20161128221459g:plain

通常のサーフェスシェーダとの違いとして、#pragma surface 文のオプションに tessellatetessDistance() が指定されています。これはパッチ単位で実行されるハルシェーダに相当し、ここで分割度合いを決定しています。この中では Tessellation.cginc で定義されている UnityDistanceBasedTess() で計算した結果を返しており、この戻り値は float4 で、xyzSV_TessFactorwSV_InsideTessFactor が入っています。そして vertex で指定している dispドメインシェーダに相当し、ここでディスプレースメントマッピングの処理をしています。

サーフェスシェーダから頂点シェーダ・フラグメントシェーダへの変換の都合上仕方ないですが、頂点シェーダへの入力の appdata にコード上は登場しない normaltangenttexcoord が必要なのがちょっとアレですね。ただ自前で全部書くよりは大分楽ですし、距離ベースでなく辺の長さが一定になるようにしたい場合も UnityEdgeLengthBasedTess() の代わりに UnityDistanceBasedTess() を使うだけで簡単に書き換えられます。また、ディスプレースメントマッピングではなく単に滑らかにしたい場合は、オプションに Phone Tessellation を行う tessphong:_Phong を指定して、tessllatte で指定した関数では何もしない形にするだけで OK です。

頂点・フラグメントシェーダ

一方、頂点・フラグメントシェーダシェーダに組み込みたい場合はもう少し細かいところまで書かないとなりません。といっても準備や計算が多いだけで、通常のテッセレーション付きのシェーダとの違いは UnityEdgeLengthBasedTess() をパッチ単位のハルシェーダに渡すところだけです。頂点、フラグメントシェーダへの組み込みに関しては、以前、以下のエントリで触れましたので詳細はそちらをご参照ください。

ではコードを見てみます。簡単のために、ライティングなどの処理はごっそり省いています。そちらもサーフェスシェーダ相当でしっかり書きたい場合は以下のエントリをご参照ください(ただしディファードのお話なのでフォワードの場合は一部異なります)。

Shader "Tessellation"
{

Properties
{
    _Color("Color", color) = (1, 1, 1, 0)
    _MainTex("Base (RGB)", 2D) = "white" {}
    _DispTex("Disp Texture", 2D) = "gray" {}
    _MinDist("Min Distance", Range(0.1, 50)) = 10
    _MaxDist("Max Distance", Range(0.1, 50)) = 25
    _TessFactor("Tessellation", Range(1, 50)) = 10
    _Displacement("Displacement", Range(0, 1.0)) = 0.3
}

SubShader
{

Tags { "RenderType"="Opaque" }

CGINCLUDE

#include "Tessellation.cginc"

float _TessFactor;
float _Displacement;
float _MinDist;
float _MaxDist;
sampler2D _DispTex;
sampler2D _MainTex;
fixed4 _Color;

struct VsInput
{
    float3 vertex   : POSITION;
    float3 normal   : NORMAL;
    float2 texcoord : TEXCOORD0;
};

struct HsInput
{
    float4 f4Position : POS;
    float3 f3Normal   : NORMAL;
    float2 f2TexCoord : TEXCOORD;
};

struct HsControlPointOutput
{
    float3 f3Position : POS;
    float3 f3Normal   : NORMAL;
    float2 f2TexCoord : TEXCOORD;
};

struct HsConstantOutput
{
    float fTessFactor[3]    : SV_TessFactor;
    float fInsideTessFactor : SV_InsideTessFactor;
};

struct DsOutput
{
    float4 f4Position : SV_Position;
    float2 f2TexCoord : TEXCOORD0;
};

HsInput vert(VsInput i)
{
    HsInput o;
    o.f4Position = float4(i.vertex, 1.0);
    o.f3Normal   = i.normal;
    o.f2TexCoord = i.texcoord;
    return o;
}

[domain("tri")]
[partitioning("integer")]
[outputtopology("triangle_cw")]
[patchconstantfunc("hullConst")]
[outputcontrolpoints(3)]
HsControlPointOutput hull(InputPatch<HsInput, 3> i, uint id : SV_OutputControlPointID)
{
    HsControlPointOutput o = (HsControlPointOutput)0;
    o.f3Position = i[id].f4Position.xyz;
    o.f3Normal   = i[id].f3Normal;
    o.f2TexCoord = i[id].f2TexCoord;
    return o;
}

HsConstantOutput hullConst(InputPatch<HsInput, 3> i)
{
    HsConstantOutput o = (HsConstantOutput)0;
    
    float4 p0 = i[0].f4Position;
    float4 p1 = i[1].f4Position;
    float4 p2 = i[2].f4Position;
    float4 tessFactor = UnityDistanceBasedTess(p0, p1, p2, _MinDist, _MaxDist, _TessFactor);

    o.fTessFactor[0] = tessFactor.x;
    o.fTessFactor[1] = tessFactor.y;
    o.fTessFactor[2] = tessFactor.z;
    o.fInsideTessFactor = tessFactor.w;
           
    return o;
}

[domain("tri")]
DsOutput domain(
    HsConstantOutput hsConst, 
    const OutputPatch<HsControlPointOutput, 3> i, 
    float3 bary : SV_DomainLocation)
{
    DsOutput o = (DsOutput)0;

    float3 f3Position = 
        bary.x * i[0].f3Position + 
        bary.y * i[1].f3Position +
        bary.z * i[2].f3Position;

    float3 f3Normal = normalize(
        bary.x * i[0].f3Normal +
        bary.y * i[1].f3Normal + 
        bary.z * i[2].f3Normal);

    o.f2TexCoord = 
        bary.x * i[0].f2TexCoord + 
        bary.y * i[1].f2TexCoord + 
        bary.z * i[2].f2TexCoord;

    float disp = tex2Dlod(_DispTex, float4(o.f2TexCoord, 0, 0)).r * _Displacement;
    f3Position.xyz += f3Normal * disp;

    o.f4Position = mul(UNITY_MATRIX_MVP, float4(f3Position.xyz, 1.0));
        
    return o;
}

fixed4 frag(DsOutput i) : SV_Target
{
    return tex2D(_MainTex, i.f2TexCoord) * _Color;
}

ENDCG

Pass
{
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma hull hull
    #pragma domain domain
    ENDCG
}

}

Fallback "Unlit/Texture"

}

UnityEdgeLengthBasedTess() はローカル座標系に対して適用するため、頂点シェーダでは特に何も変換せずにそのまま渡しています。そのため、MVP 変換はドメインシェーダで行っています。ローカル座標系で膨らませたいかワールド座標系で膨らませたいかで計算順序が変わってくるので注意してください(例ではローカル座標系で膨らませています)。

モデルとの距離でのテッセレーション

なお、ポリゴン単位でなくてモデル単位で分割度合いを変えたい場合はモデル・ビュー行列から位置を取り出してカメラから見たモデルのルートまでの距離を使って計算してあげれば OK です。PN Triangles を使う時は分割数が不連続だとポリゴンに隙間が空いてしまうので、そういった時に良い選択肢ではないでしょうか。

HsConstantOutput hullConst(InputPatch<HsInput, 3> i)
{
    HsConstantOutput o = (HsConstantOutput)0;
    
    float distance = length(float3(UNITY_MATRIX_MV[0][3], UNITY_MATRIX_MV[1][3], UNITY_MATRIX_MV[2][3]));
    float tessFactor = pow(_TessFactor / distance, 2);
    o.fTessFactor[0] = o.fTessFactor[1] = o.fTessFactor[2] = o.fInsideTessFactor = tessFactor;
           
    return o;
}

f:id:hecomi:20161128230931g:plain

おわりに

特に VR では、LOD によるモデルのポッピングやバンプマッピングの不自然さが目立つため、テッセレーションの役割が大きくなってきています。ただ無制限に使ってしまうと GPU リソースをたくさん使ってしまうので、距離や状況に応じてコストを調整しながら使用していきましょう。

謝辞

本エントリでのサンプルは以下のアセットを利用させていただいています。