はじめに

本記事はUnity Advent Calendar 2017の5日目の記事です。
前日は、tsubakiさんの【Unity】Timelineで敵の”出現タイミング”や”動き”を制御してみる でした

マリオデ

皆さんはスーパーマリオオデッセイはプレイしましたか!
物理ベースレンダリングでリアル寄りな質感を目指しつつも、マリオらしいデフォルメが効いた絶妙なバランスの絵作りが面白いですね。

特に面白いと思ったのがこの雲海(?)の表現です。
https://twitter.com/kaiware007/status/932221440400670722

なんとも柔らかそうで濃密そうな独特な質感の雲海です。
特徴をまとめてみると下記のような感じでしょうか。

特徴

  • 上下にうねうねしている
  • 移動体があると下方向に凹む(これが一番特徴的)
  • フォグのように奥に行くほど濃くなっている
  • 微妙にリムライトがかかっている
  • シャドウがかかっている

今回はマリオデの雲海を一部Unityで再現してみました。
完成形の動画はこちら↓

雲海

今回再現する特徴

  • 上下にうねうねしている
  • 移動体があると下方向に凹む
  • フォグのように奥に行くほど濃くなっている
  • シャドウがかかっている

完成品のサンプルは下記のURLにあります。
https://github.com/kaiware007/UnitySeaOfClouds
Unity2017.2.0f3で作成しました。
一部ComputeBufferを使用しているのでWindows以外では動かないかもしれません。

サンプルをもとに、特徴を順番に説明していきます。

上下にうねうねしている

マリオデの動画をよく見ると、微妙に輪郭がカクカクしている感じです(Twitterの動画ではわかりにくいですが)
そこから分割した平面ポリゴンの頂点を揺らしていると推理しました。
まずは、いい感じに分割した平面ポリゴンを用意します。
が、Unityには標準で「任意の分割数の平面ポリゴンを作成する機能」がないので用意します。
今回、技術書典3で頒布した「Unity Graphics Programming」の「第1章 Unityではじめるプロシージャルモデリング」のサンプルプログラムを利用させていただきました。
https://github.com/IndieVisualLab/UnityGraphicsProgramming

シェーダー側

頂点シェーダー内で、頂点のワールド座標を元に FBM(Fractional Brownian Motion)というノイズ関数で上下にうねうねさせます。

CloudSea.shader
float3 GetWorldPos(float4 pos)
{
    return mul(unity_ObjectToWorld, pos).xyz * _WaveNoiseScale + _NoiseSpeed * _Time.y;
}

float GetNoise(float4 vertex)
{
    float3 wpos = GetWorldPos(vertex);

    // ノイズで歪ませてみる
    vertex.y += (fbm(wpos) - 0.5) * _WaveHeight;

    // オブジェクトの位置をへこませる
    vertex.y -= GetWorldPositionTextureHeight(vertex);

    return vertex.y;
}

:

v2f raymarch_vert(appdata_full v)
{
    v2f o;

    float4 pos = v.vertex;

    v.vertex.y = GetNoise(v.vertex);
    o.localPos = pos;

    o.pos = UnityObjectToClipPos(v.vertex);
    // ラスタライズしてフラグメントシェーダで各ピクセルの座標として使う
    o.screenPos = o.pos;
    o.worldPos = mul(unity_ObjectToWorld, v.vertex);

    TRANSFER_SHADOW(o)

    float3 normal = GetNormalNoise(pos);
    o.worldNormal = normal;

    return o;
}

移動体があると下方向に凹む

シェーダー側で凹ませる位置を判定するために、RenderTextureに特定のオブジェクトの座標を書き込むプログラムを作成しました。

PositionWriter

オブジェクトの座標と円の半径を後述のPositionTextureに送っているだけです。
RenderTextureに位置を書き込ませたいオブジェクトに割り当てます。

PositionWriter.cs
public class PositionWriter : MonoBehaviour {

    public float radius = 1;
    public float heightBase = 1f;

    // Update is called once per frame
    void Update () {
        Vector2 pos;
        Vector3 pos3d = transform.position;
        pos.x = pos3d.x;
        pos.y = pos3d.z;

        float r = Mathf.Clamp01((heightBase - pos3d.y) / heightBase) * radius;

        PositionTexture.Instance.AddPosition(pos, r);
    }
}

PositionTexture

PositionWriterから受け取った座標をRenderTextureのUV座標に変換し、それを中心に円を描画しています。
円を描画したRenderTextureやテクスチャに関する情報をShader.SetGlobal***にセットしています。

PositionTexture.cs
// 座標データ追加
public void AddPosition(Vector2 pos, float radius)
{
    if (positionDataIndex >= maxPositonDataNum)
        return;

    Vector3 myPos = transform.position;
    pos.x = pos.x - myPos.x;
    pos.y = pos.y - myPos.z;

    float scale = worldScale * 1.0f;
    positionDataArray[positionDataIndex].position.x = pos.x / scale * 0.5f + 0.5f;
    positionDataArray[positionDataIndex].position.y = pos.y / scale * 0.5f + 0.5f;
    positionDataArray[positionDataIndex].radius = radius / worldScale;
    positionDataIndex++;
}

void UpdateTexture()
{

    positionDataBuffer.SetData(positionDataArray);

    material.SetBuffer("_PositionBuffer", positionDataBuffer);
    material.SetInt("_PositionIndex", positionDataIndex);
    material.SetFloat("_FadeoutPower", fadeoutPower);

        // フェード処理用に前フレームのテクスチャをコピー
        Graphics.Blit(positionTexture, positionTextureOld);

        // 円描画
        Graphics.Blit(positionTextureOld, positionTexture, material);

        // GlobalTextureでセット
        Shader.SetGlobalTexture("_PositionTexture", positionTexture);
        Shader.SetGlobalFloat("_PositionTextureScale", worldScale);
        Shader.SetGlobalVector("_PositionTextureOffset", transform.position);

        positionDataIndex = 0;
    }
PositionWriter.shader
float4 frag (v2f i) : SV_Target
{
    // old texture
    float col = DecodeFloatRGBA(tex2D(_MainTex, i.uv));

    // 円描画
    float circleCol = (float)0;
    for(int j = 0; j < _PositionIndex; j++){
        float2 pos = _PositionBuffer[j].position;
        float radius = _PositionBuffer[j].radius;
        float len = smoothstep(0, 1, radius / length(i.uv - pos));
        circleCol += saturate(len);
    }

    return EncodeFloatRGBA(saturate(circleCol + col) * (1.0 - _FadeoutPower));
}

EncodeFloatRGBAは、float値を32bitで出力するためのUnityのヘルパー関数です。
float値をfloat4で出力してくれます。
コレで精度があがります(多分)

円を書き込んだテクスチャが下記になります。
オブジェクトの座標に円を描いたRenderTexture

CloudSea.shaderのGetNoise関数の中で円を書き込んだRenderTextureを参照し、下方向に頂点を下げていきます。

CloudSea.shader
// PostionTexture内でのUV座標に変換
float2 GetWorldPositionTexturePosition(float3 pos)
{
    return (pos.xz - _PositionTextureOffset.xz) / _PositionTextureScale * 0.5 + 0.5;
}

float GetWorldPositionTextureHeight(float4 pos)
{
    float2 posUV = GetWorldPositionTexturePosition(mul(unity_ObjectToWorld, pos).xyz);
    float col = DecodeFloatRGBA(tex2Dlod(_PositionTexture, float4(posUV, 0,0)));

    return col * _HolePower;
}

float GetNoise(float4 vertex)
{
    float3 wpos = GetWorldPos(vertex);

    // ノイズで歪ませてみる
    vertex.y += (fbm(wpos) - 0.5) * _WaveHeight;

    // オブジェクトの位置をへこませる
    vertex.y -= GetWorldPositionTextureHeight(vertex);

    return vertex.y;
}

フォグのように奥に行くほど濃くなっている

Depthを取って、雲海ポリゴンの平面との距離に応じて透明度を変えるようにしています。
Cameraを2つ使います。
あらかじめ、Depthを撮るためのSub CameraをMain Cameraの子として配置します。
流れは下記のとおりです。

  1. Sub Cameraで雲海以外のオブジェクトのDepthTextureを作成する。
    Sub Cameraには、雲海以外のオブジェクトが映るようにCullingMaskを設定する
  2. Main Cameraで雲海をレンダリングする
    その際1で作ったDepthTextureを使う

DepthTextureをRenderTextureに書き込むイメージエフェクトを、Sub Cameraに追加します。
RenderTextureをカメラのTargetTextureにセットし、Depthが書き込まれたRenderTextureをこれまたShader.SetGlobalTextureでセットしています。

depthWriter.cs
using UnityEngine;

public class DepthWriter : CustomImageEffect {

    public RenderTexture depthTexture;

    private Camera cam;

    protected override void Awake()
    {
        base.Awake();

        // Depth書き込み用RenderTexture作成(DirectX9の場合、DepthはR32floatらしい)
        depthTexture = new RenderTexture(Screen.width, Screen.height, 32, RenderTextureFormat.Depth);
        depthTexture.wrapMode = TextureWrapMode.Clamp;
        depthTexture.filterMode = FilterMode.Bilinear;
        depthTexture.antiAliasing = 1;
        depthTexture.dimension = UnityEngine.Rendering.TextureDimension.Tex2D;
        depthTexture.depth = 24;
        depthTexture.useMipMap = false;
        depthTexture.autoGenerateMips = false;
        depthTexture.anisoLevel = 0;
        depthTexture.hideFlags = HideFlags.HideAndDontSave;
        depthTexture.Create();

        if (cam == null)
        {
            cam = GetComponent<Camera>();
            cam.depthTextureMode = DepthTextureMode.Depth;
        }

       cam.targetTexture = depthTexture;

    }

    public override string ShaderName
    {
        get { return "Custom/CustomDepth"; }
    }

    protected override void UpdateMaterial()
    {
        Shader.SetGlobalTexture("_CustomCameraDepthTexture", depthTexture );
    }

}

CameraのDepthTextureの値をRenderTextureに書き込んでいるだけです。

CustomDepth.shader
Shader "Custom/CustomDepth"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }

    SubShader
    {
        ZTest Always
        Cull Off
        ZWrite Off
        Fog{ Mode Off }
        Tags{ "RenderType" = "Opaque" }

        Pass
        {
            CGPROGRAM
            #include "UnityCG.cginc"
            #pragma vertex vert
            #pragma fragment frag
            #pragma only_renderers d3d9 d3d11 glcore gles gles3 metal xboxone ps4 
            #pragma target 3.0

            struct v2f 
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD1;
            };

            UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
            sampler2D _MainTex;

            v2f vert(appdata_img  v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = MultiplyUV(UNITY_MATRIX_TEXTURE0, v.texcoord.xy);
                return o;
            }

            float4 frag(v2f i) : SV_TARGET
            {
                fixed4 col = tex2D(_MainTex, i.uv);

                return tex2D(_CameraDepthTexture, i.uv);
            }
            ENDCG
        }
    }

}

CloudSea側

ピクセル単位でDepthTextureのDepth値と、雲海ポリゴン表面のDepth値の差分を取り、それをもとに濃度(アルファ値)を出しています。
差分が小さいほど浅いので薄く、大きいほど深いので濃くなります。
また、MaxDistanceを変更することで濃度が変わります。

イメージ図
Depth比較の図
赤い線がDepth値の差分です。

CloudSea.shader
// デプス取得
float GetDepth(float3 pos)
{
    float4 vp = mul(UNITY_MATRIX_VP, float4(pos, 1.0));
#if UNITY_UV_STARTS_AT_TOP
    return vp.z / vp.w;
#else
    return (vp.z / vp.w) * 0.5 + 0.5;
#endif
}

float GetDepthNearFar(float depth)
{
    return lerp(_ProjectionParams.y, _ProjectionParams.z, depth);
}

float2 GetDepthUV(float2 screenPos)
{
    float2 uv = (screenPos.xy + float2(1, 1)) * 0.5;

#if UNITY_UV_STARTS_AT_TOP
    uv.y = 1.0 - uv.y;
#endif
    return uv;
}

float GetDepthTex2D(float2 uv)
{
    return  Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CustomCameraDepthTexture, uv).x);
}

raymarchOut raymarch(float2 screenPos, float3 worldPos, float3 rayDir)
{
    raymarchOut o;

    float maxDistance = _MaxDistance;

    o.pos = worldPos;

    float2 uv = GetDepthUV(screenPos.xy);

    float depth = GetDepthTex2D(uv);
    float rayDepth = Linear01Depth(GetDepth(o.pos));

    float dist = min(GetDepthNearFar(depth - rayDepth), maxDistance);

    o.length = dist;
    o.pos += rayDir * o.length;

    return o;
}

half4 raymarch_frag(v2f i) : SV_TARGET
{
    i.screenPos.xy /= i.screenPos.w;

    float3 normal = GetNormalNoise(i.localPos);

    float4 diff;
    diff.rgb = ShadeSH9(half4(normalize(mul(normal, (float3x3)unity_WorldToObject)), 1));
    diff.a = 1;

    raymarchOut rayOut;

    float3 rayDir = GetRayDir(i.screenPos);

    rayOut = raymarch(i.screenPos, i.worldPos, rayDir);

    float dens = saturate(rayOut.length / _MaxDistance * _Density);

    fixed shadow = SHADOW_ATTENUATION(i);
    half nl = max(0, dot(normal, _WorldSpaceLightPos0.xyz));
    fixed3 lighting = diff + nl * _LightColor0 * shadow;

    return half4(_Diffuse.rgb * lighting, dens);
}

シャドウがかかっている

UnityのShadowCasterやShadowReceiveにほぼおまかせです。

ライティング&シャドウを受ける

LightModeForwardBaseにします。
#pragma multi_compile_fwdbaseを定義します。
AutoLight.cgincをインクルードし、頂点シェーダーでTRANSFER_SHADOW(o)を呼びます。
そして、フラグメントシェーダー内でライトの色や影で明るさを変更しています。

CloudSea.shader
Tags{ "LightMode" = "ForwardBase" }

Blend SrcAlpha OneMinusSrcAlpha

CGPROGRAM
#pragma vertex raymarch_vert
#pragma fragment raymarch_frag
#pragma target 3.0
#pragma multi_compile_fwdbase
#include "AutoLight.cginc"

//ライトの色
fixed4 _LightColor0;

struct v2f
{
    float4 pos      : SV_POSITION;
    float4 screenPos    : TEXCOORD0;
    float4 worldPos     : TEXCOORD1;
    float3 worldNormal  : TEXCOORD2;
    float4 localPos     : TEXCOORD3;
    SHADOW_COORDS(4)
};
CloudSea.shaderのraymarch_vert内
TRANSFER_SHADOW(o)
CloudSea.shaderのraymarch_frag内
// 球面調和
float4 diff;
diff.rgb = ShadeSH9(half4(normalize(mul(normal, (float3x3)unity_WorldToObject)), 1));
diff.a = 1;
:
fixed shadow = SHADOW_ATTENUATION(i);
half nl = max(0, dot(normal, _WorldSpaceLightPos0.xyz));
fixed3 lighting = diff + nl * _LightColor0 * shadow;

return half4(_Diffuse.rgb * lighting, dens);

雲海の影

このままだと雲海の形に沿って、オブジェクトの影が曲がったりしないのでShadowCasterパスを追加します。
LightModeShadowCasterにします。
#pragma multi_compile_shadowcasterを定義します。
ForwardBaseの時と同じように、頂点シェーダーでGetNoiseで頂点を上下に揺らしていますが、その後はTRANSFER_SHADOW_CASTER_NORMALOFFSET(o)を呼ぶだけにしてます。
フラグメントシェーダーは更に短くなっており、SHADOW_CASTER_FRAGMENT(i)のみです。
return 0;でも普通に影の処理がされるので、頂点シェーダーが重要なようです。

CloudSea.shaderのShadowCasterパス
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode" = "ShadowCaster" }

    Fog {Mode Off}

    ZWrite On 
    ZTest LEqual 
    Cull Back

    CGPROGRAM
    #pragma vertex vert_shadow
    #pragma fragment frag_shadow
    #pragma target 3.0  
    #pragma multi_compile_shadowcaster
    #pragma fragmentoption ARB_precision_hint_fastest
    #include "UnityCG.cginc"

    struct v2f_shadow { 
        V2F_SHADOW_CASTER;
    };

    v2f_shadow vert_shadow(appdata_base v)
    {
        v2f_shadow o;

        float4 pos = v.vertex;
        float3 wpos = GetWorldPos(v.vertex);

        v.vertex.y = GetNoise(v.vertex);

        TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)

        return o;
    }


    float4 frag_shadow(v2f_shadow i) : SV_Target
    {
        SHADOW_CASTER_FRAGMENT(i)
    }

    ENDCG
}

ShadowCasterの処理で頂点シェーダー側で凹ませるテクスチャを参照しているせいか、ガタガタな感じの影ができてしまっているのが気になりますが、概ねそれっぽい感じには近づけられたかもしれません。

まとめ

なんとなくそれっぽくはなったでしょうか…。
実際のマリオデの方はもっと全然違うやり方をしてるかもしれませんが、あれこれ考察しながら再現してみるのも勉強になりました。

参考資料

Unity でオブジェクトスペースのレイマーチをやってみた
Forward RenderingのShadowingについて調べてみた
Forward Renderingについてまとめてみた

明日

明日は@baba_sさんの「Unity プロジェクトにスクリプトを追加せずにエディタを拡張する方法(Unity.exe をカスタマイズしよう)」です。