Amebient Advent Calendar 7日目、続きです。
Amebientの世界描画1 https://t.co/UZmSGcq66k
— phi16 (@phi16_) December 6, 2020
**本題はここから。**諸々細かい話も増やしていきますね…。
DropRain
各Rain
に対応して存在する、落ちてくる水滴のVisualです。
これらは勿論そうなんですけど…
この滴る水滴も、重要な要素の1つです。バルブが比較的観察しやすいと思う。
さてどこから書くかな…
まず「定期的に垂れてくる水滴」を作るにあたり、これを解釈しなければなりません。本来は降ってきた雨が構造上ある一点に集約され、限界を超えると一滴落ちる、という状況だと思います。そして水が少なかった状態に戻る。何故リズムになっているのかは… 私はオートマトン的解釈をしていますが (つまり残る水の量によって表面張力とかで「次に耐えられる水量」が変わる)、まぁ「楽しいから」でも1つの回答だと思います。
これをVisualにするにあたり、まず水滴部分はどうにかなります。そして地面に到達したら波紋が出るのもまぁ自然で1、さらに地面を見ることでリズムが読めるという側面も持っています。
これらに加え、楽器に当たったら当たった感、鳴らした感が出てほしいという願いがありました。そこで飛沫に加えて波紋を空中に出すという選択をしました。明らかに空中に波紋は出ません。でもこれは発音の抽象として存在しているのです。特に地面の波紋と見た目が接続しているので好ましいところと言えます。これらのおかげで「水滴を弾き、音が鳴る」ことが自然に解釈できるようになっていると、私は思っています。
さて。**在るものには始まりと終わりがあります。**水滴の終わりは波紋です。始まりは…天井。だから、天井に溜まっている水そのものが必要になってしまうのです。これがないと水滴を落とせないわけ。降ってくる雨の方は「天」が始まりで、それは見えないので良いんですけどね。
水滴
見た目に関してはFallRainとほぼ同じです。ただし、こっちの方が太く、青みがかっているという違いがあります。これによって差別化を行っていて… 特になんかnormalの計算が (見ての通り) ぐちゃぐちゃなんですがわざと残した気がします。こっちのほうが「存在性」が強かったので。敢えて水として世界に溶かさせずに、顕在させる為に。綺麗ですし。
あとFallRainとの違いは長さもありますね。これは速度に比例した長さを取っていて、つまり段々長くなります (あっちは終端速度ということになってるからあれでいいの)。
さて、シェーディング部分はこんなことになってます。
struct Rain {
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 worldPos : TEXCOORD1;
float4 grabPos : TEXCOORD2;
float4 projPos : TEXCOORD3;
float3 normal : TEXCOORD4;
float3 tangent : TEXCOORD5;
float4 data : TEXCOORD6;
};
#define ComputeView(arg) \
ComputeCamera##arg; \
float3 viewDir = normalize(i.worldPos - cameraPos); \
float3 forward = normalize(mul(transpose((float3x3)UNITY_MATRIX_V), float3(0,0,-1))); \
float3 eyeViewDir = viewDir / dot(viewDir, forward); \
float eyeDepth = LinearEyeDepth(tex2D(_CameraDepthTexture, i.projPos.xy / i.projPos.w)); \
float3 collision = cameraPos + eyeViewDir * eyeDepth; \
float depth = distance(collision, i.worldPos); \
float3 groundDepth = depth * abs(dot(viewDir, i.normal));
#define ComputeBump(uv,ln) \
float2 texUV = uv; \
float3 bump1 = UnpackNormal(tex2D(_Bump, texUV+_Time.y*0.01*float2(1,0.3))); \
float3 bump2 = UnpackNormal(tex2D(_Bump, texUV+_Time.y*0.009*float2(-0.8,-1))); \
float3 bump = normalize(bump1 + bump2 + ln);
#define ComputeRefr(grab) \
float2 waterUV = i.grabPos.xy / i.grabPos.w; \
float2 grabAspectRatio = float2(_##grab##GrabTex_TexelSize.z / _##grab##GrabTex_TexelSize.w,1); \
float2 refrShift = bump.xy / grabAspectRatio; \
float worldDist = distance(i.worldPos, cameraPos); \
float refrAmount = smoothstep(0, 2, 1 / worldDist);
#define ComputeFresnel(grab,ffac) \
float4 col = float4(0,0,0,1); \
waterUV += refrShift * refrAmount; \
float3 refr = tex2D(_##grab##GrabTex, waterUV); \
float3 reflDir = normalize(reflect(viewDir, normal)); \
float3 refl = DecodeHDR(UNITY_SAMPLE_TEXCUBE(unity_SpecCube0, reflDir), unity_SpecCube0_HDR); \
float fresnel = 1 - pow(1 - abs(dot(viewDir, normal)), ffac); \
col.rgb = lerp(refl, refr, fresnel);
float4 dropWater(Rain i, float3 localNormal, float distort, float mode, float ripple) {
// mode = 0: normal, 1: surface, 2: top drop
ComputeView(Stereo);
ComputeBump(i.uv*0.01 + i.worldPos.xz * (1-ripple), localNormal.xzy);
float3 binormal = cross(i.normal, i.tangent);
float3 normal = normalize(bump.x * i.tangent + bump.y * binormal + bump.z * i.normal * (mode > 1.5 ? 0.2 : 1));
ComputeRefr(Rain);
refrAmount *= distort * smoothstep(0, 1, depth);
ComputeFresnel(Rain, mode > 1.5 ? 1 : mode > 0.5 ? 30 : 2.0);
col.rgb *= _Color.rgb;
col.a = _Color.a * smoothstep(0.0, 0.2, abs(dot(i.normal, -viewDir))) * smoothstep(0.1, 0.2, worldDist);
col.a *= smoothstep(0, 0.5, localNormal.y);
if(i.worldPos.y > oceanLevel()-0.01) groundDepth = min(groundDepth, i.worldPos.y - oceanLevel());
if(abs(mode-1) < 0.5) col.a *= smoothstep(0.03, 0.02, groundDepth);
return col;
}
水系の描画は多く行いそうだったので、統一的に処理できるようにRain
構造体を作ったりマクロにしたりして共通処理をやりやすくしてあります。勿論ここに書いてないことも色々あります… あとこれが効率悪かったり間違ってるところもある。丁寧に何をやっているかを書くと、次のようになります。
- 視点関連
- カメラ位置を
cameraPos
に格納 -
_CameraDepthTexture
を使ってその位置のワールド座標を計算 (collision
) - 描画点からの距離を
depth
を格納 - 薄い板の上にあるメッシュの場合はその板からの距離を擬似的に計算 (
groundDepth
)
- カメラ位置を
- 法線関連
- オリジナルの法線に適当なnormal mapを適当に (加算で) 乗せる
-
i.worldPos.xz
は乱数の種として使っているみたいですね…
-
- 正直ノリが9割だと思う。どうせ正確じゃなくてもこういうのは気づかないんですよね。速いし。
- オリジナルの法線に適当なnormal mapを適当に (加算で) 乗せる
- 得た
bump
によって法線を適当に傾けてnormal
に保存。 - 屈折関連
- 本来のサンプル位置は
waterUV
- 屈折を真面目にやるのは無理なので諦めて適当に
bump
でオフセットを乗せる - 遠ければ遠いほど屈折量 (
refrAmount
) を減らす (減るので)
- 本来のサンプル位置は
- フレネル項っぽいの関連
- 屈折成分 (
refr
) と反射成分 (refl
) を計算 - Schlick近似の気持ち (
F0 = 0
) でフレネル項っぽいものを計算 (fresnel
)- この
ffac
という値、5でもなんでもない好きな値が入ってるので…
- この
- あとは混ぜる
- 屈折成分 (
- 後は色を載せて
- 縦方向から見たときと近すぎるときにフェードアウトする処理を入れて
- 外周へ行くにつれてフェードアウトする処理を入れて
- 海に入るときは (貫通した上で) フェードアウトする処理をやって
- おわり
視点関連のごちゃごちゃはこんな感じです…。groundDepth
の計算は波紋が地面からどれだけ離れてるかを知るのに必要なのです、depth
だと一様な距離にならないので。
まぁあれです。**主にノリです。**かっこよく見えたら勝ちです。気になる箇所を見つけたら修正します。それだけです。
水滴の生成位置はリズムに合わせてあるわけですが、これはRain
の持っているリズムデータと位相データ (RainBaker
がテクスチャに焼いてくれたもの) を現在時刻と照合することで行っています。
加えてリアルタイムで書き込まれる「どこに衝突物体があるか情報」2を使うことで「ここまでは水滴、これ以上は波紋と飛沫」という判定を行っています。
実はDropRain
は各Rain
に対して64個の描画機会を提供します。それらは8つにわかれ、各々が「1回の水滴落下」を司ります (最大になるのは全開のバルブの場合)。各々8個の描画機会は「水滴」「波紋」「5つの飛沫」「天井の水」に使われています。
…なんて説明すればいいんだろう?要は全部1つのシェーダに全部押し込まれているというだけなんですけど。
波紋
地面まで落下した波紋は素直で、まぁ置くだけです。この場合一番大きく広がるようになってます。
金属に当たると先述の通り空中に出ます。
この向きは「衝突法線をちょっと揺らがせたもの」、中心は「衝突法線方向にちょっとずらしたところ」ところです。そのままだと一定すぎるというのと、ちょっと中心をずらしてあげないと平坦な場所だと楽器に埋まってしまうのですね。
また、水面に乗ったときは逆に安定した波紋が出ます。これは「発音の抽象」ではなく「水に落ちた」という解釈ですね。この違いは「書き込まれた衝突法線が特殊値 (2,0,2)
を取る」ことで判定しています。特に意味はない。
それぞれ大きさとかフレネルっぽいの計算用値とか調節してるんですけどまぁそれは… 調節しただけです。いい感じに。
波紋の大きさは pow(animTime, 0.5)
なので最初は一気に広がって段々ゆっくり。くり抜いてる部分は…なんか複雑なので説明できないんですけど、まぁ段々1
に近づけていけば自然に消せるのはお分かりの通りです。
飛沫
衝突点とその法線、そして衝突からの経過時間がわかっています。あとはぐっと。
float aRand = rand(float2(rhy.g,k*3+0)) * 3.1415926535 * 2;
float rRand = rand(float2(rhy.g,k*3+1)) * 0.5;
float sRand = rand(float2(rhy.g,k*3+2)) * 0.5 + 0.5;
float3 velocity = float3(sin(rRand)*sin(aRand),-cos(rRand),sin(rRand)*cos(aRand));
velocity *= 2 * sRand;
velocity = reflect(velocity, collisionNormal);
center += velocity * realTime;
center.y -= pow(realTime, 2.0) * 9.8 / 2.0;
velocity.y -= realTime * 9.8;
tangent = normalize(velocity);
落下する雨の速度がある程度ランダムだと仮定して、それを跳ね返します。そしてそれに従って位置を移動、速度も計算、そして速度を正規化したものが向きです。
あとはいい感じにします。
size = 0.015 * float2(0.25, length(velocity) + 0.5);
float animTime = realTime * lerp(1, 3, sRand) * lerp(1, 2, smoothstep(0.9, 1.0, collisionNormal.y));
if(animTime > 1.0) return;
size.y *= 1 - pow(animTime, 2.0);
size.y *= 1 - exp(-animTime*20);
つまり上を向いてるほど長生きして、生存期間は最大で3倍差あって、落ちていくと段々縮んでいくと。最後の2つの乗算はこんな動きをするようです。
まぁだから急に現れて段々小さくなっていきます。そんなもんですね。
小さくならなきゃいけないのは消す必要があるからで、消す必要があるのはこれはエフェクトだからと言えそうです。地面まで落ちるには邪魔なんです。衝突の衝撃を表す抽象なので、その瞬間だけを提供できれば十分なのです。
天井
実は一番苦労したのがこれです。
動かないので、誤魔化せないんですよ。
どの方向から見ても水滴っぽく見える不思議なビルボードです。これは。
ちゃんと垂れます。
これは本当に所謂「ビルボード」で、面の法線が視点を向いています。気持ちはRayMarchingです。
が、形状がほぼ単純…と言えるので、2Dお絵かきをしてどういう3D形状になるかを定義しています。
float width = 0.01f, height = 0.05f;
float fac = sqrt(1-normal.y*normal.y);
height = lerp(width, height, fac);
center += lerp(float3(0,-1,0), tangent, fac) * height;
center.xz -= float2(normal.x, normal.z) * width * fac * normal.y;
size = float2(width, height);
これは「横から見ると長く、下から見ると短くなるビルボード」の実装。特に上から見られる可能性もあったので中心位置を丁寧に弄る必要がありました。
確かこんな動きのはずです…。
で、それで得たポリゴンに対してお絵かきをするのがこれ。
float drop(float2 p, float3 m) {
float tilt = m.x;
float t = m.y;
bool secondActive = m.z > 0.5;
float d = 0;
float mult = lerp(lerp(1.3, 0.5, smoothstep(0, 0.5, t)), 1.3, 1-exp(-t*1));
mult = lerp(mult, 3, smoothstep(0.05, 0, abs(t-0.05)));
mult = lerp(1, mult, smoothstep(-0.1, 0.1, p.y));
float2 u = p * float2(1,lerp(1,1/mult,1-tilt));
float firstDrop = 1.0*lerp(exp(-t*10), 1, 1-exp(-t*2));
float secondDrop = 0.3*lerp(0.2,1,smoothstep(0.05,0.1,t))*smoothstep(0.3,0.15,t);
d += firstDrop / dot(u,u);
float h = 0.05;
float speedAdjust = 3;
u = p - float2(0,pow(t,2)*9.8*0.5*speedAdjust/h+t*5*exp(-t*1.25))*(1-tilt);
mult = 4;
u.y *= lerp(1,1/mult,sqrt(1-tilt*tilt));
if(secondActive) d += secondDrop / dot(u,u) * smoothstep(0, 1, p.y);
if(p.y > 0) d = d * lerp(1, smoothstep(1,0,pow(abs(p.x),2)), pow(p.y,0.8) * (1-tilt));
return d < 1 ? -0.1 : sqrt(1-1/d);
}
p
が (ちょっと不思議な) UVを指していて、m
は傾きの情報とかが格納されてます。
この関数は2つの雫が連なった領域を表す2D図形の距離関数です。任意の高さから見た状態での…。説明できないので shadertoy に置いておきました。マウスで「高さ」を変えられます。
メタボールみたいな気持ちです。調節色々入ってるけど…。はい。それっぽくするために頑張りました。過去の私が。
…どうしてこんなことしたんでしょうね。それ以外にまぁ思いつくものは無いけど…。
で、この関数は実は距離ではなくどちらかというとheight mapみたいな役割を持っていて。grad取ると法線が出るので、それでシェーディングをしています。こう。
float2 e = float2(0.001, 0);
float3 n = float3(drop(p+e.xy,m) - drop(p-e.xy,m), drop(p,m)*e.x*2*8, drop(p+e.yx,m) - drop(p-e.yx,m));
clip(n.y);
n = normalize(n);
というわけでいい感じになりました。ありがとうございました。
この8
はなんなんだろう。e.x*2
は微小差分量なんだけど。…まぁなんか平坦っぽく見せたかったのかな。
この上にそのまま水滴を重ねて描画しちゃってるんですが、水滴は半透明成分が入ってるので割とバレずに見えてる気がしますね。
そういえば入り口のあのテーマを奏でる部分は、雨粒の落下ではなく「配管から漏れた水」になってます。だからそれぞれ配管の位置から出るようになってて、微妙に位置調節に苦労した…。初期状態でたらいに当たっている水の場所ってここなんですよ。
この境界、ベベル入ってるからちゃんと法線が上むいてるんです。だから音量をちゃんと大きくできていて。そして真上にはギリギリ配管があって。
これが出来なかったら最初の位置の配置全部やりなおしてたかもしれない。怖いね。
白い線
真上に水滴の位置があるとはいえ、空中に配置するときには補助がほしかったのです。でも主張はしてほしくない。なのでまぁギリギリこれくらいかな、というところ。
多分「どこに落ちるんだろう」と考えた時点で既に眼に入っているし、今回の役割はその時点で果たしています。何故なら明らかに地面に垂直に建っていて、頂上には溜まった水があるからです。UIが何を表すかを明示的に語らずとも、存在だけで機能を語っているのです。
ちなみにこれは豪華に六角柱で出来ています。何故…?
他の話
見えるものとしてはこんなものですが、内部的にちょっとおもしろい (?) 動作をする部分があります。
この雨は基本的に規則正しい動きをしますが、規則を乱された瞬間がちょっと問題です。即ち「楽器が新たに入ってきた場合」。その位置に0.1秒前に在ったら鳴って波紋が出ていたようなケースでは、このままでは実際に波紋が出てしまうのです。鳴ってないことを知らないので。
そこで、最後の移動イベントを記録して、それ以前のエフェクトは出さない、という動作をしています。これが「波紋が出てる状態で楽器を上下すると波紋が消える」理由です。まぁ端的に言えばパーティクル的振る舞いじゃないから。消えるよりも現れる方が人は認識しやすい (してしまう) という側面もありますね。
このモニタ群 (4つ) は内部バッファです。
左が「落下距離」「最終イベント時間」、右上が「法線」、右下は… 上が位置データで下がリズムデータかな (Bakeされたもの)。
最終イベント時間は青成分に記録されてて、32拍過ぎるとリセットが掛かるようになってます。これによって周期性をどうにか取り戻してます。
ちなみに白い線に基準位置 (雷の周期と一致するタイミング) を表示する話があったりしたんだけどこのBakeされたデータには基準位置の情報入ってないんですよね…3 前に書いた通りY=0
でぴったりなので座標棒など持ち込んでいただいて…
あと世界遷移が起きたときに一部の雨の配置が変化しています。特に細い管から落ちる雨が8個から16個になってるのは気づかれてることと思います。
これはらくとさん側からの要請なんですが、その実装は… ちょっと大変でした。元々雨の配置が確定していることを前提として頭を回していたので、そういうのが崩れるとどこまで影響が及ぶか考えるのがちょっと大変で。
最終的にはまぁ素直…なのかな。各雨がどっちの世界状態に属するかのデータを入れて、世界状態が変化したら全ての雨に対して通知を出して、あとは描画時にも現在の世界状態情報を渡す。
**遷移が発生する瞬間は雷がすごいので不連続変化を入れてもバレません。**これのおかげで細かいことを考えずに済んだところがあります。揺れのエフェクトもこれに多少寄与しているんでしょうね。
Ocean
水中の話は次に回すとして、ここでやってる謎の処理の話をします。
半径方向法線
Amebientの海は、鉄塔から波が常にやってきています。よく見るとわかります。めっちゃ顕著にして法線を出すとこんな感じ。
何故かというとこれも鉄塔の特別性を出したかったからです4。で、これはどう実装されているかというと…
float2 polarUV = float2(atan2(centerUV.x, - centerUV.y), 2) * length(centerUV);
これは円を中心から一方向に切って、三角形に変形しなおすことにあたります。
勿論角度は正しくないんですけど、唯の海の凸凹ですし…。で、このUVをさらに時間に合わせて動かしてあげると「半径方向」と「回転方向」の移動になってくれて、それを2個重ねたものを使っています。
こうした理由は凸凹の密度の一定化の為です。外側であればあるほど伸びるUVにしてしまうと…多分望ましくないです。ですよね。この変換は空間の局所面積を保つので、凸凹の密度が保たれるというわけです。
ただ完璧ではありません。
言った通りこれは「円の一方向を切り開く」動きをします。なので一方向だけ、不連続性が発生します。でもAmebientでは人は基本的にビルに居るので、こっち側を見ることはないだろう、という前提の元、これを良しとしました。
これの完璧な解決については下の方で述べることになります。
Screen Space Reflection
そして御存知の通り (?) 海は遠景を反射しています。これは遠景を2回描画しているわけはなく、Screen Space Reflectionというよくある描画テクみたいなやつです。
考え方は、DepthBufferで作られた世界の上でraymarch。まぁ、完全に水平な位置から見たら「像はほぼ対称図形である」わけで、それを拡張した気持ち、とも言えそうです。
なんとなく作ったのは 3月3日5。でもこの絵は良い方で、一般にSSRは視線が傾けば傾くほどしんどいです。
対して、遠景というのは視線方向が著しく水面と平行に近いです。これはSSRにおける理想的な状況です。なのでやってみるかーと思ってやりました。
遠景であることを最大限活用するべく、幾つかのtrickが使われています。
- 原点から100m越えるまで予めレイを進めておく
- 初期位置はjitterを入れておく (ノイズが出る代わりに離散的marchingのアーティファクトが見かけ上消える)
- 最終的な衝突点が原点から100m以内だったら非衝突と見なす
- レイは1ステップで20m進み、物体の厚みは40mと見なす
- 衝突がわかったら周辺で二分法をやってちょっとだけ精度を上げる (あんまり寄与はない)
ループ回数は20回 + 5回です。まぁありかね…。
鉄塔の形状が最悪でなかなか綺麗に出ないんですよね。あと白くなっちゃうのは空を拾っているからなんだけど、これは「その点についての深度を測って基準以上だったら黒にする」っていうので対応できたことに今気づきました。まぁいっか…。
調節
// depth is distance between drawing point and depth buffer point
col.rgb *= lerp(pow(_Color, lerp(0.3, 1.5, smoothstep(0,20,depth))), 1, exp(-depth*10));
col.rgb = lerp(baseRefr, col.rgb, smoothstep(0, 0.5, groundDepth));
col.rgb *= lerp(1, pow(_Color, 0.1) * 1.25, smoothstep(0, 0.005, depth));
col.rgb = pow(col.rgb, lerp(1, 1.2, pow(smoothstep(100,400,worldDist), 2)));
他と同様に細かい調節がめちゃくちゃ入ってます。順に「壁に接するところではベース色を白色にする」「壁に接するところは壁の色と同化する」「全体を少し明るくする」「水平線付近を暗くする」です。
屈折関連の小さな調節も含めればなんかいっぱいあってどうしようもないので説明はしません。まぁ、気に入るまで弄った結果です。
あ、1つ話としては… 屈折を適当にやると海面より上側の位置を参照することがあって(手をかざすと何故か海に映るの、ありますよね)。 それを防ぎたかったので「一度計算した屈折に基づく衝突位置が海面より上のとき、屈折を無くす」という処理が入ってます。不連続性を生んでしまうんですけど手が映るよりマシかなと… これを連続にしようとすると何度もサンプリングするしか無いでしょうし。
ちなみにそういえば海はdisplacementとかも何も入っていません。最初は「波」をちゃんと作ろうと思ってシミュレーション見たりしてたんですけど、穏やかな世界に落ち着かせることにしました。浮かんでる白い泡とかも海には大切な要素だと思うんですが、まぁ衝突する物体はビルくらいしかないし… 気象的にはやばい世界ですけどね、これについては私の技量不足に近いかなー…。
まぁ、displacementが出来なかった理由は水中があるから、というのはあります。「水面の高さ」が一様に定まってこそできる処理がいくつかあるのですよ。
Underwater
海があって入れそうだったので水中も欲しかった。特にちゃんとした水中が。
いろいろやってます。はい。
メッシュ構造
水の中を作るのはいろいろ大変で。全てが水中なら最初からそうすればいいんですが、この世界には海面という境界があります。
そして私達は左目と右目を持っています。いえ、そうとは限らずともカメラのNearClipによって両側が映ることがあります。
これによって「ある条件で描画を切り替える」ようなことは出来ませんし、視界ジャックも出来ません。視点位置に従って正しく描画を行うことをするしかありません。
視界ジャック的処理がベースとなるのは自然ですが、その範囲は海面を越えてはいけません。そこで、頭の周囲をキューブで囲むことにしました。
o.worldPos.xyz += _WorldSpaceCameraPos.xyz;
o.worldPos.y = min(o.worldPos.y, oceanLevel());
if(_WorldSpaceCameraPos.y > oceanLevel() + 1) o.worldPos = 0;
囲みつつ、各頂点は海面を越えないように移動します。縮退すると困るので一番左のシチュエーションでは実際は単純に消してます。そして描画はCull Front
及びZTest Off
で、これによってOceanと競合することなく理想的な描画領域の確保を行うことができます。
ベースシェーディング
水中の「遠い程暗い」感じはfogとほぼ同じ概念 (散乱) で、つまり「物体までの距離を測って指数関数的減衰で背景色に落とす」のが基本です。そうしました。
それには「物体までの距離」、正確に言うと「物体に当たるまでに貫通した海の長さ」を計算しないといけないんですが… 整理するまでよくわからんかった。
「上を向いていて、海面まで視線が到達するときは海面上の衝突点までが水中」です。そうだね。あと海面以外では屈折量は0にしています (密度一定なので屈折しない)。
refr = lerp(_Color*0.02, refr, exp(-max(0, depth-4)*0.12));
微妙に調節が入ってる (depth-4
)。4mまでは色が乗らないということっぽいですね。
ちなみにこのdepthによる方法はちょっとした問題があって…。
ポリゴン境界にジャギが乗ります。これはポリゴンレンダリングに使われるMSAAの仕組みに因るもので、海が映っている場所も手前にDepthが書かれているからです。MSAAを切ったのが下。
とは言え元々この柱はベベルの影響で端周辺の明るさの変化が激しく、(MSAAではどうにもならない) ピクセル単位での線が出ていて。まぁ深く考えなくていいか、という気持ちでそのまま使うことにしました。
あと悲しい話として、Unlit系やtransparentのアバターはdepthを測れないので遠いと判定されて海に溶けるというものがあります。これはdepth系の処理だと尽く起きてしまうやつで… 申し訳ない。VirtualLensのドキュメントに付随情報が付いてるので気になる方は参照くださると良いかと思います。私の髪飾りも (修正前のは) 溶けてました。
Light Shaft
何よりもビルが良いんです、これ。だからビルの美しさを際立たせる効果にもなっているんですよね。
貯水槽から出てるのも好き。みんなで遊んだ動画のエンドでめちゃくちゃ綺麗に映ってて。良い。
内部的に使われてるテクスチャは機械フロアにも置いてあります。色が違うけど。
— phi16 (@phi16_) June 24, 2020最初はこの大きさで、それは即ち**上まで海面が到達することを想定していなかった**ということだったりします。あとなんとなく法線も出力してるけど最終的に消えた (要らなかったので)。
さて実装ですが、まぁ素直と言えば素直、です。元々アバターによる遮蔽は (しんどそうなので) やめようと思っていて6、そうすると予め深度をBakeすることができる。太陽の向きに合わせて平行投影カメラを置いて、1:2のテクスチャとして出力。カメラの位置と向きをそのままMaterialパラメータで与えることで「ワールド座標から太陽から見た深度」を計算することができるようになります。実際には計算はほぼ「太陽から見たView空間」で行っていますね7。
float3 worldDir = normalize(toPos - fromPos);
float3 viewFromPos = appQ(invQ(_SunRotation), fromPos - _SunPosition.xyz);
float3 viewToPos = appQ(invQ(_SunRotation), toPos - _SunPosition.xyz);
float3 diff = viewToPos - viewFromPos;
float3 viewDir = normalize(diff);
marchingのステップ幅は0.3mで、60回。後はSSRと似たような感じで…。まぁちょっと違うか。
海面に近ければ近いほど光が届きやすいようにはなってたりしますね、
大変だったのは「アーティファクトを減らすこと」「光の強さが強すぎず弱すぎないこと」「綺麗なこと」で、まぁそれは長い調節の結果です…。やっぱりここでも汎用性の犠牲がそこそこあって、それによって良くなれた部分は多くあると思っています。
…そういえば逆にアーティファクトが有効活用されてるところもあった。
この線、Bakeされた深度テクスチャ8のピクセル境界です。太陽が斜め向いてるのでこうなっちゃうんですよね。深度は正しく計算できないのです。**でもこの線は良いなと思った。**空間自体には何もないから本来は一様な色味になるんだけど、画像という空間にはピクセル単位があるから一様でない面白いディティールが出る。
まぁこれは影におけるジャギでしかないので一般にはよくない話なんですけど、light shaftに関してはありかな、くらいの気持ちです。
Skybox
さいご。長い記事もようやく終わりです。
めちゃくちゃ良いな…。
空、動いてほしいですよね。…誰がここまで、と言うかもしれませんが、まぁその反応は期待通りでしょうか。
天候がどんどん悪くなっていくわけで、それを表現するにあたっては空をちゃんと作るしかありません。雷まで出てきちゃうので雷雲も欲しい。
鉄塔を中心に捉えていたときにCapと話していたのが「鉄塔の上に渦巻いた雲があって穴が開いている」みたいなやつで、まさしくそれに近いことをやっていたのがARTIFICIAL TYPHOONでした。これを見て固形の雲もありだなーと思ったんですが、気づいたらこうなってました。
座標空間
まずこれは空です。概ね「ある高さを仮定してそこまでレイを飛ばす」ことが一般的かと思ったんですが、それだと地平線付近で異常に遠い場所を参照しちゃうんです。それはなんだか違う。
地球上に居る場合、地平線は大体5kmくらいの遠さであるみたいな話があります。つまり無限遠ではありません。
ちゃんと「星」を前提にしてあげると、レイの飛ぶ先は球です。
float radius = 20000;
float3 towerCenter = float3(0, 100.0, 286.9);
float tr = 1 + towerCenter.y/radius, r = 1 + o.y/radius;
// x^2 + 2*r*d.y*x + r^2-(t+r)^2 = 0
float thinDist = - d.y*r + sqrt((d.y*d.y-1)*r*r + tr*tr);
float3 skyPos = o + d*thinDist*radius - towerCenter;
星の半径20km、空までの高さは100m9。めっちゃくちゃ小さいです。でも誇張するくらいがいいかなって思った。
二次方程式になるのでぐっと解いて空への衝突点が出ます。この点が雲の計算の開始点です。
空色
雲を作るにしてもまずその裏にある「大気の散乱」を作る必要があります。まぁそんな真面目に計算しているわけではありません。
どうあれ常にどんより雲なので、基本的にはノイズをいい感じに載せてるだけです。わざわざ6つも重ねてるらしい。
float on(float2 uv) {
float2x2 m = float2x2(0.6,-0.3,0.3,0.6)*2;
float a = 0;
float t = Time*0.2;
uv.x += t;
a += noise(uv);
uv.y += t*1.5;
uv = mul(m,uv); a += noise(uv)*1.4;
uv.x -= t*2;
uv = mul(m,uv); a += noise(uv)*0.5;
uv.y -= t*2;
uv = mul(m,uv); a += noise(uv)*0.3;
uv.x += t*3;
uv = mul(m,uv)*1.7; a += noise(uv)*0.4;
uv.y += t*4;
uv = mul(m,uv); a += noise(uv)*0.3;
return a;
}
調節が細かい…。自分でもよくわかりません。ちなみにここで受け取るuv
は前述の衝突点とは違って、空の高さを800mとして計算した版のX座標とZ座標、っぽいです。
あとこれに太陽の強さとかが一応乗ってます。正直細かい調節ばかりでなんとも言えない感じが…。
雲の造形
レイを飛ばします。まず密度場から…
float density(float3 p, int i, inout RenderParams param) {
float density0 = param.p0.x;
float density1 = param.p0.y;
float yDetail = polarNoise(p.xz*0.0024, 2.4, param);
float v =
lerp(
lerp(
polarNoise(p.xz*0.0001, 0.5, param),
polarNoise(p.xz*0.0013, 0.8, param)
, 0.213),
1 - lerp(
yDetail,
polarNoise(p.xz*0.013, 3, param)
, 0.515)
, 0.113);
v = lerp(density0, density1, v);
return saturate(v - yDetail * pow(i/10.0, 1.5) * 0.4);
}
えー。その前に話すことがあった。
Amebientの雲は鉄塔を中心に渦巻いています。その為に雲の密度場は「回る」必要があります。が、これは単にUVを回せばいいというものではありません。雲の移動速度は遠くても一定であるべきです。
そこで考えたのが、リング状に分割されたUVです。
中心からi
番目のリングは1/i
倍速で回ります。そうすると各点での見かけの速度が一致し、またこれらを境界でブレンドしてあげることでなめらかな図形が出来ます。
float polarNoise(float2 uv, float tf, inout RenderParams param) {
float center = param.p1.y;
float2 dx = ddx(uv); // TODO: extract
float2 dy = ddy(uv);
float s = floor(length(uv)*8);
float fs = frac(length(uv)*8);
float t = Time * 0.05 * tf;
float a0 = t / (s+1);
float2 uv0 = mul(float2x2(cos(a0),-sin(a0),sin(a0),cos(a0)), uv);
float a1 = t / (s+2);
float2 uv1 = mul(float2x2(cos(a1),-sin(a1),sin(a1),cos(a1)), uv);
float sh = lerp(-0.0252, 0.005, center);
return lerp(tex2Dgrad(_Noise, uv0 + sh, dx, dy).a, tex2Dgrad(_Noise, uv1 + sh, dx, dy).a, fs);
}
で、それを4枚重ねて密度場にしています。低域と高域みたいな感じで。
一番右が最終で、更にこれには「遠い程に薄くなる」処理も入っています (i
に依存してる項)。これが実は一番立体感に寄与しています。体感ですが。
まず全体の色味が違いますけど、まぁ右だと出ている凸凹のディティールが左だとほぼ無いのが見てわかると思います。
さて、この密度場をレイに貫通してもらいます。幅2m、10回。
float col = 0, dens = 0;
for(int i=0;i<10;i++) {
float3 p = o + step*(i+r)*d;
float d = density(p, i, param);
float de = smoothstep(0.6, 0.65, d) * 0.1;
float lightFac = lerp(shadow0, shadow1, d);
col = lerp(lightFac, col, dens);
dens = lerp(dens, 1.5, de);
if(dens > 1) {
dens = 1;
break;
}
}
…なにやってるんすかね… なんか気持ちだと思います…。本来雲の影をちゃんと描画するには各点で更に太陽に向かってレイを飛ばす必要があったんですが、それをやるには計算コストが大きくてやめたんです。代わりに適当にいい感じの色味にならんかな、と思ってやったのがコレだと思います。恐らく。
解釈をちょっと考えていたんですが思いつきません。なんかうまくいってるのでまぁいいということにします。
あと実はもうちょっと違います。
上が実際に使われてる方。下が今紹介した方。端的に言うとレイを飛ばす回数を半分にしています。ただ、ライティングの計算は2倍やってます。密度場の計算が重かったので、どうせ1ステップで変化する密度量はそんなに大きくないということを仮定して、1度に2ステップ分の密度を計算します。テクスチャサンプル回数が半分になります。ちょっとディティールは落ちるけど許容範囲でしょう。
ちなみに使ってるテクスチャはこれです。
テクスチャ変えたらガラッと雰囲気変わったのでびっくりしました。まぁそれもそうよね…
抽象的なエフェクトが多い中、建物と空の重みによって空間そのもののディティール性を結構高められているんじゃないかな、と思ってます。文字通りちょっと重いんすけどね。
パラメータ
これで出来た空を世界に従って動かす必要がありました。とは言えやってることは「雲を増やす」「雲の色を暗くする」「世界全体を暗くする」くらいです。
雲の量は適当な閾値をベースとしてやっていたので制御は簡単でした。影色もそのまま乗せてるだけでしたし。
パラメータは6つあって、密度の閾値2つ、影色2つ、空の色、そして雲を中心に持ってくるかどうかです。
雷が落ちるまでは5つのパラメータを単に線形補間しているんですが、さらに雲が落ちた後は段々大きな雲の塊が鉄塔に近づいていきます。実はこれ偶然出来たんですけど、かっこよかったので使いました。
世界状態は勿論Udon制御なんですが、さらに (DropRainと同様に) 時間もUdon制御です。これによって段々雲の移動・回転をシームレスに加速できるようになっています。最終的に5倍速になります。
雷
雷に合わせて空が光ります。雷本体の明るさ不足を空で補っている感じですね。
光るタイミングに合わせて加算で色を乗せてます。marchingするときに同時に計算してるので自然に濃淡が出ていますね。
float3 thunderLight(float3 tp, float3 o) {
float t = (ThunderTime + 2) / 2;
float d = distance(o, tp);
float et = exp(-max(0,t-0.5)*1);
float expStr = lerp(0.03, 0.05, et);
float vStr = lerp(0.3,0.5,et);
float str = exp(-d*expStr)*vStr + 150*pow(et,2)/pow(d+5, 1.1);
str *= 1 - exp(-max(0,t)*40);
if(t > 0) str += exp(-t*10) * 10 * ThunderStrength;
str *= smoothstep(5, 3, t);
return _ThunderColor.rgb * str * _WorldOverride;
}
これも気持ちで出来ています。リズムのタイミングよりも2拍前から開始して、指数関数的に弱くなりつつ、距離減衰は大凡1/d
で出来ていて… いろいろ。無理やり色を消す為に smoothstep(5, 3, t)
を使ってたりしていますね。ううむ。
こちらも世界遷移の時は強くなるようにThunderStrength
に依存した何かになっています。
そんなところかなー…。
おわり
制作解説ってなんなんでしょうね…。願いの話はこっち側ではあんまり出来なかったですね。「ただ存在させる」ことのほうが難しいという気持ち、あります。
今回はあんまりまとめるようなことも言えなそう。各々、できる限り良くしてみた、くらいだと思います。
正直もうこんなに頑張りたくないという気持ちが強いですが、どうせまた何かやるときに必要になったらやるんだと思います。
いろいろ実装経験出来て楽しかったです。レイ飛ばす系3つも (SSR, LightShaft, Cloud) ありますし…。代わりにそれらのせいで負荷がそこそこ来ていることは否定できませんが。
特に雲は、作っているとき (Unity) ではわりと平気かと思ってたのにVRで見てみたら20FPSすら出てないみたいなことがあって…。うちの比較的しんどい環境 (RTX2060ノートなんだけどね?ね?) で負荷調節したのでそれ以上の人にはこれくらいで大丈夫なのかなと予想しています。多分。
ここまで細かい点を見られているとかはあんまり思ってないんですが、それは「そこに唯在るだけのディティール」にとっては最も望ましいことです。自分の中では全体的に満足は行っています。
沢山の人々、見に来てくださってありがとうございます。
さて、次回はもっと技術…というか算数の話になる予定です。願いを叶えるって大変ですね。