Edited at

[Unity]無限平面を描画する過程でGPUの理解を深める

無限平面というのはこういうのです。

tmp.png

デフォルトの背景としてはSkyboxを出すのが一般的ですが、地平にできたら嬉しいケースはけっこうあると思うんですよ。建物のスキマが奈落になってるよりはずっと良い、みたいな。

今回は無限に続く平面をなるべく軽く、なるべく綺麗に描画してみましょう。


頂点シェーダでカメラの前方に矩形を置く

out.png

このようにカメラと描画領域(視錐台)があった場合、描画領域の中を指定Y座標の平面が埋め尽くすように配置したいわけです。


モデルを準備

out.png

適当な大きさの平面モデルを用意します。Unityが標準で用意してくれているQuadでもPlaneでも良いですが、Planeのようにある程度細かく分割されていたほうが精度的に有利になります。さらに、UnityのPlaneオブジェクトは$y=0$平面なのに対してQuadオブジェクトは$z=0$平面なので、地面にするならPlaneが便利。とりあえずPlaneを使っておきましょう)


視錐台の中央を算出

out.png

用意したモデルを、頂点シェーダでカメラの前方の$y=0$の地点に配置します。大きさも、描画領域を埋め尽くすような大きさに拡大します。

v2f vert(appdata v)

{
float3 forward = -UNITY_MATRIX_V._m20_m21_m22; // カメラの前方ベクトル
float3 campos = _WorldSpaceCameraPos; // カメラのワールド位置
float center_distance = abs(_ProjectionParams.z - _ProjectionParams.y) * 0.5; // near と far から視錐台の中央までの距離を得る
float3 center = campos + forward * (center_distance + abs(_ProjectionParams.y)); // 平面を移動すべき中心点
float3 pos = float3(v.vertex.x * center_distance * 0.5 + center.x,
0, // ground level
v.vertex.z * center_distance * 0.5 + center.z); // 移動後の頂点
v2f o;
o.vertex = UnityWorldToClipPos(pos); // クリップ座標へ

コードは途中ですが、フラグメントシェーダに渡す頂点座標に拡大&移動済みの頂点が出力されています。


uv座標にワールド座標

uv座標に頂点のワールド座標をそのまま投入します。

tmp.png

先ほどの頂点シェーダに3行ほど追加、uv座標の指定を加えて、頂点シェーダは完成です。

v2f vert(appdata v)

{
float3 forward = -UNITY_MATRIX_V._m20_m21_m22; // カメラの前方ベクトル
float3 campos = _WorldSpaceCameraPos; // カメラのワールド位置
float center_distance = abs(_ProjectionParams.z - _ProjectionParams.y) * 0.5; // near と far から視錐台の中央までの距離を得る
float3 center = campos + forward * (center_distance + abs(_ProjectionParams.y)); // 平面を移動すべき中心点
float3 pos = float3(v.vertex.x * center_distance * 0.5 + center.x,
0, // ground level
v.vertex.z * center_distance * 0.5 + center.z); // 移動後の頂点
v2f o;
o.vertex = UnityWorldToClipPos(pos); // クリップ座標へ

// uv座標にpos情報を投入(サイズ調整のために1/16を掛けている)
o.uv = TRANSFORM_TEX(pos.xz*float2(1.0/16.0, 1.0/16.0), _MainTex);
UNITY_TRANSFER_FOG(o, o.vertex);
return o;
}

ワールド座標をuv座標に入れてしまう、というのがある種のトリックです。


ここまでの結果

フラグメントシェーダは特に工夫しないものを用意して、

fixed4 frag(v2f i) : SV_Target

{
col = tex2D(_MainTex, i.uv);
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}

こうなります。カメラを動かしても、どこまでも地平が続きます。

tmp2.png

なお、原点から離れるとUVの精度が落ちるのが、この方式の制限と言えるでしょう。例えばカメラ座標を移動したとき、デスクトップでは問題なくても、モバイル機だと精度がガックリ落ちたりするので注意が必要です。


リピートをなんとかしたい

先ほどの画像、テクスチャのリピートが気になりませんか?テクスチャの繰り返しが整然と見えていて、自然とはかけ離れた絵になっています。

余談。最近気付いたのですが、これが別に気にならない人も多いんですね。そこを画像の特徴とはとらえていない。僕はリピートが気になって仕方がないんですが、むしろそんな人のほうが少数派なのかもしれません。

それはさておき、シェーダでリピートをなんとかしていきます。


UVをずらす作戦

原理としては、UVをランダムにずらします。

でも、そうすると不連続な境界が目立って問題になるはずです。そもそも地面のテクスチャは、タイリングで敷き詰められた際に境界が連続するように(苦労して)作られていますよね!

こういうときは、境界付近のテクスチャを数回フェッチしてブレンドします・・・としたいところですが、

テクスチャのフェッチはメモリアクセスなので、速度を考えるとなるべく回数を増やしたくありません。

まずはブレンドはやらずに、境界が目立って困る、という問題を起こし、それを観察してみましょう。


ハッシュ関数

ハッシュとランダムは似たような意味ですが、使い方が異なります。

どちらも「バラバラな値」を得るわけですが、ハッシュは

「毎回その都度、入力があるもの」

と呼べますかね。入力が同じ値なら出力も同じ値になることが期待されます。

ありがたいことにいろいろな実装が公開されています。今回は質より速度を重視して、

https://briansharpe.wordpress.com/2011/11/15/a-fast-and-simple-32bit-floating-point-hash-function/

こちらのサイトに載っていたものを使わせていただきます。

float4 hash4fast(float2 gridcell)

{
const float2 OFFSET = float2(26.0, 161.0);
const float DOMAIN = 71.0;
const float SOMELARGEFIXED = 951.135664;
float4 P = float4(gridcell.xy, gridcell.xy + 1);
P = frac(P*(1/DOMAIN)) * DOMAIN;
P += OFFSET.xyxy;
P *= P;
return frac(P.xzxz * P.yyww * (1/SOMELARGEFIXED));
}


ハッシュでUVをずらしてみた


フラグメントシェーダの前半部

fixed4 frag(v2f i) : SV_Target

{
float4 off = hash4fast(floor(i.uv));
off.zw = ((step(0.5, off.zw)) - 0.5) * 2;

変数offにはxyzwの4つの値が格納されます。zwについては+1か-1かの符号をハッシュによるランダムで格納します。

これは、単にUVをずらすだけでなく、時折テクスチャの方向を反転するのに使います。この工夫はリピート感をなくすのにたいへん効果があります。


余談:Use not sign but step.

+1,-1 の符号を得るのに sign関数を使ってはダメです。HLSLでもGLSLでも、sign(0)はゼロを返す仕様なんですよね。今回の場合は掛け算に使用されるので、ゼロは許容できません。上のようにstep関数を使って符号を得ます。

(ちなみにUnityのMathf.Sign(0)は1を返します)


さらに余談:Use not step but select.

かなり細かい話になりますが、実は「stepを使う理由はない」という話もあるみたいです。stepは場合により効率が悪いとか、コードがわかりにくくなりやすいとか。なのでstepの行は

    off.zw = off.zw >= float2(0.5, 0.5) ? float2(1, 1) : float2(-1, -1);

こうします。この3項演算子はbranchではなくselectになるはずで、stepよりも高い効率が見込まれます。ベクトルに3項演算子を適用するのは奇妙ですけど(ベクトルを大小比較する時点ですでに奇妙ですけど)、この式はちゃんと(-1, 1)や(1, -1)も返します。


フラグメントシェーダの全体

残りを記述して、UVをランダムでずらします。

fixed4 frag(v2f i) : SV_Target

{
float4 off = hash4fast(floor(i.uv));
off.zw = off.zw >= float2(0.5, 0.5) ? float2(1, 1) : float2(-1, -1);
float2 fuv = frac(i.uv);
float2 uv = fuv * off.zw + off.xy;
fixed4 col = tex2D(_MainTex, uv);
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}

ハッシュにはfloorしたものを渡し、計算にはfracを使います。つまり引数の i.uv は1.0単位で扱われていることに注意。その結果、UVをずらす効果は1.0単位で現れます。


ここまでの結果

見事な結果を得られました。

tmp.png

境界にアーティファクトが現れると予想していたのですが、ぜんぜん問題ないなあ。

どうやらこれは、テクスチャに依存するようです。

つまり、どんなテクスチャでも大丈夫なわけではないんですよね。わかりやすいテクスチャで試すとこうなります。フォグもオフにして、こう。

tmp2.png

かなり境界の不連続が目立ちます。ダメなテクスチャもあるわけです。

が、いろいろなテクスチャで試してみると、意外と多くのパターンの地面(あるいは雲海や海面)の画像で問題ないことがわかります。

というわけで、完全なる汎用性さえ捨てれば、テクスチャフェッチは1回で良いですね。これでよしとしましょう。


チラつき問題を解消する

UVをずらす作戦は成功しましたが、まだ問題があります。これは動いている画面を見ると一目瞭然なのですが、やたらとチラつくのです。

実はここからが、このエントリで書きたかったテーマになります。前置き長かった!


mipmapの選択に失敗している

先ほどの描画結果の遠方を拡大してみます。

tmp.png

注意深くみないとわかりませんが、ところどころ「ボケ」度合いが均一ではないのがわかります。これがチラつきの原因で、理由はmipmapの選択に失敗しているからです。


mipmapの正しい選択

tex2Dという関数は、ハードウェアにより巧妙に作られていて、同時に実行される隣接ピクセルの処理と組み合わせて動作します。ここに渡されるuvが連続していることが前提になっていて、隣接ピクセルとの微分値でmipmapが選択されるのです。(この仕掛けの巧妙さには感動を覚えます)

なので、今回の作成のように適当にuvをいじってしまうと問題が起きるのです。ではどうするか。

これが正しいテクスチャフェッチです。

fixed4 frag(v2f i) : SV_Target

{
float4 off = hash4fast(floor(i.uv));
off.zw = off.zw >= float2(0.5, 0.5) ? float2(1, 1) : float2(-1, -1);
float2 fuv = frac(i.uv);
float2 uv = fuv * off.zw + off.xy;

// correct mipmapping
float2 dx = ddx(i.uv) * off.zw;
float2 dy = ddy(i.uv) * off.zw;

// correct fetch
fixed4 col = tex2Dgrad(_MainTex, uv, dx, dy);

UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}

tex2Dgradという、勾配を渡すテクスチャフェッチAPIがちゃんと用意されているんですよね。ddx, ddyによって得た微分値を渡します。このddx, ddyもすごいAPIで、同時に実行されている隣接ピクセルとの差をゲットしてしまうという、GPUの特徴が如実に現れた機能です。トリッキーな操作をしない、普通のuvの値でddx,ddyを実行しておいて、その結果をmipmap選択のヒントにするようにtex2Dgradに与えるわけです。

out.png

左が修正後です。たとえば赤い円の部分がわかりやすいですが、正しいmipmapが選択されるようになりました。


完成

改めて完成図。動かしてもチラつきは現れません。

tmp.png

いかがでしたでしょうか。フラグメントシェーダで実行されるtex2Dがかくも繊細であることを知ると、GPUの機微が感じられるようになります(ぼくは感じました)。このへんを理解するとvertex shaderではtex2Dlodを使う必要があるのも納得できます。

それにしても、こんな仕組みを作り上げた人類はすごいなと。

めでたしめでたし。


参考


Anatomy of a Texture Fetch

https://www.slideshare.net/Mark_Kilgard/anatomy-of-atexturefetch/26


Unity Blog : Procedural Stochastic Texturing in Unity

本格的にリピートの解消に取り組んだプラグイン

https://blogs.unity3d.com/jp/2019/02/14/procedural-stochastic-texturing-in-unity/


最終的なシェーダコード

Shader "Custom/ground_with_fog"

{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType"="Opaque" }
Cull Off
LOD 100

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fog // make fog work

#include "UnityCG.cginc"

struct appdata
{
fixed4 vertex : POSITION;
fixed2 uv : TEXCOORD0;
};

struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};

sampler2D _MainTex;
fixed4 _MainTex_ST;

// https://briansharpe.wordpress.com/2011/11/15/a-fast-and-simple-32bit-floating-point-hash-function/
float4 hash4fast(float2 gridcell)
{
const float2 OFFSET = float2(26.0, 161.0);
const float DOMAIN = 71.0;
const float SOMELARGEFIXED = 951.135664;
float4 P = float4(gridcell.xy, gridcell.xy + 1);
P = frac(P*(1/DOMAIN)) * DOMAIN;
P += OFFSET.xyxy;
P *= P;
return frac(P.xzxz * P.yyww * (1/SOMELARGEFIXED));
}

v2f vert(appdata v)
{
float3 forward = -UNITY_MATRIX_V._m20_m21_m22;
float3 campos = _WorldSpaceCameraPos;
float center_distance = abs(_ProjectionParams.z - _ProjectionParams.y) * 0.5;
float3 center = campos + forward * (center_distance + abs(_ProjectionParams.y));
float3 pos = float3(v.vertex.x * center_distance * 0.5 + center.x*0.2,
0, // ground level
v.vertex.z * center_distance * 0.5 + center.z*0.2);
v2f o;
o.vertex = UnityWorldToClipPos(pos);
o.uv = TRANSFORM_TEX(pos.xz*float2(1.0/16.0, 1.0/16.0), _MainTex);
UNITY_TRANSFER_FOG(o, o.vertex);

return o;
}

fixed4 frag(v2f i) : SV_Target
{
float4 off = hash4fast(floor(i.uv));
off.zw = off.zw >= float2(0.5, 0.5) ? float2(1, 1) : float2(-1, -1);
float2 fuv = frac(i.uv);
float2 uv = fuv * off.zw + off.xy;
float2 dx = ddx(i.uv) * off.zw;
float2 dy = ddy(i.uv) * off.zw;
fixed4 col = tex2Dgrad(_MainTex, uv, dx, dy);
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}

ENDCG
}
}
}