カイワレスタイル

ゲーム、アニメ、プログラム、興味のあることをツラツラと。

Thetaのストリーミング映像をリアルタイムにリフレクションさせてみた

本記事は Unity Advent Calendar 2018 の 18日目 の記事です。

qiita.com

はじめに

9/15のInfiniteRaveというイベントで、ThetaVを使って360度のストリーミング映像をSkyBoxにしたり、オブジェクトにリフレクションさせたりするVJシステムを作って使っていました。
当時はReflectionProbeを使って毎フレームSkyboxだけをターゲットにしてCubeMapを生成していたのですが、そのCubeMapのレンダリングがそこそこ重かったです。
60FPSは維持してましたが、他のオブジェクトなどの負荷も合わせると結構ギリギリなラインでした(そんなにつよつよPCではなかったせいもある)

www.youtube.com

上のアーカイブ動画の冒頭からスクリーンに映っている映像がソレです。
今回ReflectionProbeを使わずに、リアルタイムにリフレクションさせる仕組みを作ってみたのでそれの解説をします。
下が完成形の動画です。

youtu.be

サンプルプロジェクトは下記にあります。

github.com

リフレクションとは?

そもそもリフレクションとは、直訳すると「反射」です。その名の通り3DCGでは「周囲の物体を映り込ませる」表現のことを指します。
物体に映り込む光線の計算などは負荷が高いため、ゲームなどのリアルタイムの3Dグラフィックでは予めレンダリングしたリフレクションマップなどを使って計算負荷を軽減したりしています。
Unityでも、立方体状のCubeMapやReflectionProbeなどを使ってそれっぽく見せています。

UnityでThetaを使う

Thetaはカメラなので、UnityではWebCamTextureから使うことができます。
自分はThetaVを使っているのですが、ドライバーのインストールなどが必要でした。
詳細は下記の記事を参照してください。

kaiware007.hatenablog.jp

サンプルプロジェクト(ThetaVReflectionTest)のTHETAVCamTextureManager.csで、Thetaのストリーミング映像カメラデバイスの検出とストリーミングの再生を行っています。

using UnityEngine;

public class THETAVCamTextureManager : MonoBehaviour
{

    #region define
    static readonly string theta_v_FullHD = "RICOH THETA V FullHD";
    static readonly string theta_v_4K = "RICOH THETA V 4K";

    static readonly string[] thetaCameraModeList =
    {
        theta_v_FullHD,
        theta_v_4K,
    };

    public enum THETA_V_CAMERA_MODE
    {
        THETA_V_FullHD,
        THETA_V_4K,
    }
    #endregion

    public THETA_V_CAMERA_MODE cameraMode = THETA_V_CAMERA_MODE.THETA_V_FullHD;

    protected WebCamTexture _webCamTexture = null;

    public WebCamTexture webCamTexture { get { return _webCamTexture; } }

    protected virtual void Initialize()
    {
        int cameraIndex = -1;

        WebCamDevice[] devices = WebCamTexture.devices;
        Debug.Log("DevicesLength:" + devices.Length.ToString());
        for (var i = 0; i < devices.Length; i++)
        {
            for (int j = 0; j < thetaCameraModeList.Length; j++)
            {
                Debug.Log("[" + i + "] " + devices[i].name);

                if (devices[i].name == thetaCameraModeList[(int)cameraMode])
                {
                    Debug.Log("[" + i + "] " + devices[i].name + " detected");
                    cameraIndex = i;
                    break;
                }
            }
            if (cameraIndex >= 0) break;
        }

        if (cameraIndex < 0)
        {
            Debug.LogError("THETA V Not found");
            return;
        }

        _webCamTexture = new WebCamTexture(devices[cameraIndex].name);
        if(_webCamTexture != null)
        {
            _webCamTexture.Play();
        }
    }

    private void Awake()
    {
        Initialize();
    }
}

WebCamTexture.devicesでカメラデバイスを列挙して、ThetaVに該当する名称のデバイスからWebCamTextureを生成しているだけです。

Thetaのストリーミング映像のTextureをSkyboxにする

まず、リフレクションさせる前に、周囲の背景が反射してるのがわかりやすいようにThetaのストリーミング映像をSkyboxに設定してみます。
前項で生成したWebCamTextureをSkyboxのマテリアルにセットすればOKです。

using UnityEngine;

public class THETAVCamTextureSkyBox : MonoBehaviour
{
    public Material skyboxMat;

    public Material effectMat;
    public bool isEffect = true;

    public int downSample = 0;
    public FilterMode filterMode = FilterMode.Bilinear;

    public RenderTexture outputTexture;

    THETAVCamTextureManager manager;

    private void Start()
    {
        manager = GetComponent<THETAVCamTextureManager>();

        if (manager != null)
        {
            int width = manager.webCamTexture.width >> downSample;
            int height = manager.webCamTexture.height >> downSample;
            outputTexture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGBFloat, RenderTextureReadWrite.Default);
            outputTexture.useMipMap = true;
            outputTexture.autoGenerateMips = true;
            outputTexture.filterMode = filterMode;

            if (skyboxMat != null)
            {
                RenderSettings.skybox = skyboxMat;
                skyboxMat.SetTexture("_MainTex", outputTexture);
            }

            Shader.SetGlobalTexture("_ThetaTex", outputTexture);
        }
    }

    private void LateUpdate()
    {
        if(manager != null)
        {
            if (effectMat != null && isEffect)
            {
                Graphics.Blit(manager.webCamTexture, outputTexture, effectMat);
            }
            else
            {
                Graphics.Blit(manager.webCamTexture, outputTexture);
            }

            if (skyboxMat != null)
            {
                RenderSettings.skybox = skyboxMat;
                skyboxMat.SetTexture("_MainTex", outputTexture);
            }

            Shader.SetGlobalTexture("_ThetaTex", outputTexture);
        }
    }
}

今回、プライバシー保護のためThetaの映像にエフェクトをかけれるようにしています。
Graphics.Blit(manager.webCamTexture, outputTexture, effectMat);が該当箇所です。
また、後で他のオブジェクトのシェーダー側でも参照するため、Shader.SetGlobalTexture("_ThetaTex", outputTexture);でシェーダーのグローバル変数としてもセットしています。

Skyboxのシェーダーは、Unity標準のSkybox/Panoramicを使用します。

f:id:kaiware007:20181217020842p:plain
Skybox/Panoramic
Thetaの映像は正距円筒図法の形式で送られてくるので、SkyboxのMappingを「Latitude Longitude Layout」、Image Typeを「360 Degrees」にします。
これでシーンのSkyboxがThetaのストリーミング映像になります。 f:id:kaiware007:20181217023257p:plain

ThetaのTextureを使って反射させる(反射角から全天球画像のUVを求める)

いよいよ本題の、リフレクションです。
まず、反射ベクトルの求め方です。 図と式で書くと下記のようになります。

f:id:kaiware007:20181217235349p:plain
反射ベクトルの図と式

まぁシェーダーにはそのものズバリに反射ベクトルを計算する「reflect」という関数があるのでソレを使います。
次に反射ベクトルをもとに、Thetaの全天球画像のどこにあたったか、緯度と経度を求めてUV座標に変換します。
イメージとしてはskyboxを球体として、球体の中心から反射ベクトルを飛ばして球体に表面にあたった場所の色を取り出す感じです。

f:id:kaiware007:20181218003839p:plain
全天球のリフレクション
f:id:kaiware007:20181218003909p:plain
緯度と経度からUV座標を求める式

シェーダーは下記のとおりです。 ToRadialCoordsの中で緯度と経度を求めて、UV座標に変換しています。

Shader "Custom/ReflectionTest"
{
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        Pass
        {
            CGPROGRAM
          #pragma vertex vert
          #pragma fragment frag

          #include "UnityCG.cginc"

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

            struct v2f
            {
                float4 vertex       : SV_POSITION;
                float3 worldPos     : TEXCOORD0;
                float3 worldNormal  : TEXCOORD1;
                float3 pos          : TEXCOORD2;
                float2 uv           : TEXCOORD3;
            };

            sampler2D _ThetaTex;

            inline float2 ToRadialCoords(float3 dir)
            {
                float3 normalizedCoords = normalize(dir);
                float latitude = acos(normalizedCoords.y);                         // 緯度
                float longitude = atan2(normalizedCoords.z, normalizedCoords.x);   // 経度
                float2 sphereCoords = float2(longitude, latitude) * float2(0.5 / UNITY_PI, 1.0 / UNITY_PI);   // UVに変換
                return float2(0.5, 1.0) - sphereCoords;
            }

            v2f vert(appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.uv = v.texcoord;

                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                half3 worldViewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
                half3 reflDir = reflect(-worldViewDir, i.worldNormal);

                // Reflection
                float2 tc = ToRadialCoords(reflDir);
                if (tc.x > 1.0)
                    return half4(0, 0, 0, 1);

                tc.x = fmod(tc.x, 1);
                
                half4 refColor = tex2D(_ThetaTex, tc);
                
                return refColor;
            }
            ENDCG
        }
    }
}

Standard Surface Shader(Forward Rendering版)対応

ついでにStandard Surface Shader(サーフェスシェーダー)を改造してThetaの映像をリフレクションするようにしてみました。
サーフェスシェーダーでリフレクションを改造するには、「Show generated code」ボタンを押してVertex/Fragment Shaderのコードに変換して、Fragment Shaderの中のリフレクションの処理を書き換える必要があります。
サーフェスシェーダーの変換後のコードの詳細は、凹みさんのブログ記事が非常に参考になります。というか参考にさせていただきました。(いつもありがとうございます!)

tips.hecomi.com

tips.hecomi.com

今回はForward Renderingのみ対応しています。
コードの量が多いので、通常のサーフェスシェーダーからの変更箇所をメインに解説します。
Fragment Shaderの最後のあたりのLightingStandard_GIの中でリフレクションの処理を行っています。

             LightingStandard_GI_Custom(o, giInput, gi);
                c += LightingStandard(o, worldViewDir, gi);

リフレクション以外の処理はそのままにしたいので、LightingStandard_GIを一部だけ改造したLightingStandard_GI_Customという関数を定義します。

inline void LightingStandard_GI_Custom(
    SurfaceOutputStandard s,
    UnityGIInput data,
    inout UnityGI gi)
{
#if defined(UNITY_PASS_DEFERRED) && UNITY_ENABLE_REFLECTION_BUFFERS
    gi = UnityGlobalIllumination(data, s.Occlusion, s.Normal);
#else
    Unity_GlossyEnvironmentData g = UnityGlossyEnvironmentSetup(s.Smoothness, data.worldViewDir, s.Normal, lerp(unity_ColorSpaceDielectricSpec.rgb, s.Albedo, s.Metallic));
    gi = UnityGlobalIlluminationCustom(data, s.Occlusion, s.Normal, g);
#endif
}

しかし、実際のリフレクションの処理自体は、LightingStandard_GIの更に4段階ほど深いところで行っているので、そこまで掘り下げないといけません。
更に関係する関数を複製して置き換えていきます…。

inline float2 ToRadialCoords(float3 dir)
{
    float3 normalizedCoords = normalize(dir);
    float latitude = acos(normalizedCoords.y);
    float longitude = atan2(normalizedCoords.z, normalizedCoords.x);
    float2 sphereCoords = float2(longitude, latitude) * float2(0.5 / UNITY_PI, 1.0 / UNITY_PI);
    return float2(0.5, 1.0) - sphereCoords;
}

half3 Unity_GlossyEnvironmentCustom(sampler2D tex, half4 hdr, Unity_GlossyEnvironmentData glossIn)
{
    // Reflection
    float2 tc = ToRadialCoords(glossIn.reflUVW);
    if (tc.x > 1.0)
        return half4(0, 0, 0, 1);
    tc.x = fmod(tc.x, 1);

    half perceptualRoughness = glossIn.roughness /* perceptualRoughness */;
    perceptualRoughness = perceptualRoughness * (1.7 - 0.7*perceptualRoughness);

    half mip = perceptualRoughnessToMipmapLevel(perceptualRoughness);
    half4 rgbm = tex2Dlod(_ThetaTex, float4(tc, 0, mip));

    return DecodeHDR(rgbm, hdr);
}

inline half3 UnityGI_IndirectSpecularCustom(UnityGIInput data, half occlusion, Unity_GlossyEnvironmentData glossIn)
{
    half3 specular;

#ifdef UNITY_SPECCUBE_BOX_PROJECTION
    // we will tweak reflUVW in glossIn directly (as we pass it to Unity_GlossyEnvironment twice for probe0 and probe1), so keep original to pass into BoxProjectedCubemapDirection
    half3 originalReflUVW = glossIn.reflUVW;
    glossIn.reflUVW = BoxProjectedCubemapDirection(originalReflUVW, data.worldPos, data.probePosition[0], data.boxMin[0], data.boxMax[0]);
#endif

#ifdef _GLOSSYREFLECTIONS_OFF
    specular = unity_IndirectSpecColor.rgb;
#else
    half3 env0 = Unity_GlossyEnvironmentCustom(_ThetaTex, data.probeHDR[0], glossIn);

#ifdef UNITY_SPECCUBE_BLENDING
    const float kBlendFactor = 0.99999;
    float blendLerp = data.boxMin[0].w;
    UNITY_BRANCH
        if (blendLerp < kBlendFactor)
        {
#ifdef UNITY_SPECCUBE_BOX_PROJECTION
            glossIn.reflUVW = BoxProjectedCubemapDirection(originalReflUVW, data.worldPos, data.probePosition[1], data.boxMin[1], data.boxMax[1]);
#endif

            half3 env1 = Unity_GlossyEnvironmentCustom(_ThetaTex, data.probeHDR[1], glossIn);
            specular = lerp(env1, env0, blendLerp);
        }
        else
        {
            specular = env0;
        }
#else
    specular = env0;
#endif
#endif

    return specular * occlusion;
}

inline UnityGI UnityGlobalIlluminationCustom(UnityGIInput data, half occlusion, half3 normalWorld, Unity_GlossyEnvironmentData glossIn)
{
    UnityGI o_gi = UnityGI_Base(data, occlusion, normalWorld);
    o_gi.indirect.specular = UnityGI_IndirectSpecularCustom(data, occlusion, glossIn);
    return o_gi;
}

最終的にUnity_GlossyEnvironmentCustomという関数の中でリフレクションの処理をしています。

half3 Unity_GlossyEnvironmentCustom(sampler2D tex, half4 hdr, Unity_GlossyEnvironmentData glossIn)
{
    // Reflection
    float2 tc = ToRadialCoords(glossIn.reflUVW);
    if (tc.x > 1.0)
        return half4(0, 0, 0, 1);
    tc.x = fmod(tc.x, 1);

    half perceptualRoughness = glossIn.roughness /* perceptualRoughness */;
    perceptualRoughness = perceptualRoughness * (1.7 - 0.7*perceptualRoughness);

    half mip = perceptualRoughnessToMipmapLevel(perceptualRoughness);
    half4 rgbm = tex2Dlod(_ThetaTex, float4(tc, 0, mip));

    return DecodeHDR(rgbm, hdr);
}

反射ベクトルからUV座標を求めるところは前項と同じですが、面白いのが、Smoothness(Glossiness)の値によって、参照するテクスチャのMipmapレベルを変えて粗さを表現しているところです。
初めて知った時はなるほどと思いました。
そのため、Thetaのストリーミング映像にエフェクトをかける時の転送元のRenderTextureにuseMipMapとautoGenerateMipsを有効にしています。
以下はSmoothness(
Glossiness)の値を変えたときの見た目です。上から順に粗いテクスチャになっているのがわかると思います。 f:id:kaiware007:20181217230727p:plain f:id:kaiware007:20181217230752p:plain f:id:kaiware007:20181217230819p:plain f:id:kaiware007:20181217230858p:plain

まとめ

Thetaの360度ストリーミング映像を使ってリフレクションをする仕組みの解説をしました。
6面全てをレンダリングするCubeMapに比べたら多少は軽くなってるのではないでしょうか?
正直、Unityの親切なレンダリングパイプラインから若干外れた処理になってしまうので汎用性が低いです。
まぁ、個人的にはサーフェスシェーダーの勉強にはなったので良しとします(前向き)
時間があったらDeferred Renderingの方も挑戦してみたいと思います。

最後に宣伝

会社の同僚達と書いた技術同人誌
Unity Graphics Programming vol.1~3 (電子書籍版)絶賛販売中!
Unityによるシェーダーやグラフィックスプログラミングについて解説しています。

indievisuallab.stores.jp

RICOH THETA V 360度カメラ 全天球 910725 メタリックグレー

RICOH THETA V 360度カメラ 全天球 910725 メタリックグレー