e.blog

主にUnity/UE周りのことについてまとめていきます

PlayerPrefsを操作するエディタウィンドウを作成する

概要

EditorWindowを継承した、エディタ拡張のメモです。
普段、エディタ作成をそんなにしていないので忘れがちなので。

ちなみに今回は、汎用的になるようにPlayerPrefsを操作するっていう名目でサンプルを作成しました。

準備

エディタ拡張の詳細はここでは触れません。
ここではEditorWindowクラスを継承して、ウィンドウタイプのエディタ拡張を制作するまでを書きます。

まず、UnityEditor.EditorWindowクラスを継承したクラスを作成します。
ウィンドウのインスタンスは初回のみ生成するようにしています。
ウィンドウの生成にはCreateInstance<T>();スタティックメソッドを利用します。

メニューから生成されるには以下のようにします。

public class PlayerPrefsEditor : EditorWindow
{
    static private PlayerPrefsEditor _window;

    [MenuItem("Window/PlayerPrefsEditor")]
    static private void Open()
    {
        if (_window == null)
        {
            _window = CreateInstance<PlayerPrefsEditor>();
        }
    }
}

メニューには以下のように表示され、これを実行するとウィンドウが開きます。

f:id:edo_m18:20180520110044p:plain

ウィンドウの描画処理

ウィンドウが生成されると、インスペクタのエディタ拡張などと同じくOnGUIメソッドが定期的に呼ばれるようになります。
そこに、ボタンやラベルなどを表示させ、インタラクションさせることによってエディタを実装します。

private void OnGUI()
{
    EditorGUILayout.LabelField("---- PlayerPrefs List ----");
}

この中に様々な処理を書けばリッチなエディタを作ることができます。
今回は実際に使用した部分だけをメモとして残しておきます。

スクロールビューを作る

項目が増えてくるとスクロールしないと収まらなくなってきます。
そんなときはスクロールビューを実装します。

スクロールビューにしたい箇所をGUILayout.BeginScrollView()GUILayout.EndScrollView()ではさみます。

具体的には以下のようにします。

private Vector2 _scrollPos;

private void OnGUI()
{
    _scrollPos = GUILayout.BeginScrollView(_scrollPos, GUI.skin.box);

    // ... do anything.

    GUILayout.EndScrollView();
}

_scrollPosは現在のスクロール状態を保持するためにインスタンス変数として保持しておきます。

項目を枠でグルーピングする

また、項目が増えてくると各項目ごとにグルーピングしたくなってきます。
その場合はスクロールビューと似た感じでEditorGUILayout.BeginVertical()EditorGUILayout.EndVertical()のメソッドで項目をはさみます。

private void OnGUI()
{
    EditorGUILayout.BeginVertical(GUI.skin.box, GUILayout.ExpandWidth(true));

    // ... do anything.

    EditorGUILayout.EndVertical();
}

最後に、最近のプロジェクトで使ったやつを一部を伏せつつ載せておきます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using ElminaAR.Systems;

public class PlayerPrefsEditor : EditorWindow
{
    static private PlayerPrefsEditor _window;
    static private readonly string _userDataFoldoutKey = "UserDataFoldoutKey";

    private Vector2 _scrollPos;

    private bool _isFirst;

    [MenuItem("Window/PlayerPrefsEditor")]
    static private void Open()
    {
        if (_window == null)
        {
            _window = CreateInstance<PlayerPrefsEditor>();
        }

       #region ### Player Config ###
        _window._isFirst = PlayerConfig.IsFirstPlay;
       #endregion ### Player Config ###

        _window.ShowUtility();
    }

    private void OnGUI()
    {
        ShowEdit();
    }

    private void OnDestroy()
    {
        //
    }

    /// <summary>
    /// エディット画面を表示する
    /// </summary>
    private void ShowEdit()
    {
        _scrollPos = GUILayout.BeginScrollView(_scrollPos, GUI.skin.box);
        EditorGUILayout.LabelField("---- Player Config ----");
        _isFirst = EditorGUILayout.Toggle("初回プレイ: ", _isFirst);
        GUILayout.EndScrollView();

        if (GUILayout.Button("更新"))
        {
            Apply();
        }
    }

    /// <summary>
    /// PlayerPrefsを更新
    /// </summary>
    private void Apply()
    {
        PlayerConfig.IsFirstPlay = _isFirst;
    }
}

これを実際に表示すると以下のようなウィンドウになります。(一部マスク)

f:id:edo_m18:20180612095428p:plain

ライトマップデータから影の情報を抜き出し頂点カラーに焼く

概要

とあるプロジェクトで頂点カラーにライトマップの影情報を焼き込めないか、という話があって試してみました。
結局その機能自体は使いませんでしたが、ライトマップデータへのアクセスなど学びがあったのでまとめておきます。

ライトマップデータの読み取り

まずはライトマップデータの取り扱いです。
ライトマップは、Lightの「Mode」をMixedBakedにし、Lightingウィンドウの「Generate Lighting」ボタンを押すことで生成されます。

生成されたライトマップのデータはこんな感じです↓
f:id:edo_m18:20180517113239p:plain

ライトマップの扱いはLightmapDataクラスを使う

ライトマップのデータは、LightmapDataクラスを介して取得します。

ライトマップデータを取り出す

オブジェクトに紐づくデータは以下のようにして取り出します。

MeshRenderer renderer = GetComponent<MeshRenderer>();
LightmapData data = LightmapSettings.lightmaps[renderer.lightmapIndex];
Texture2D lightmap = data.lightmapColor;

RendererクラスのlightmapIndexプロパティに、該当オブジェクトのライトマップデータの配列のindexが格納されているので、それを利用してLightmapSettings.lightmaps配列からライトマップデータを取得することができます。

ライトマップデータ(テクスチャ)はLightmapData.lightmapColorに格納されています。

ライトマップデータからカラー情報を取得する

ライトマップデータを取り出したら、そこからカラー情報を取得します。
ライトマップは通常、複数オブジェクトの情報がひとつ(ないし複数)のテクスチャに、テクスチャアトラスとして書き出されます。

つまり、ライトマップデータは複数オブジェクトの情報を含んでいるため、適切にテクスチャからカラー情報を取得(フェッチ)しないとなりません。

※ 注意点として、生成されたライトマップデータは読み取りができない状態になっているので、インスペクタの設定から「Read/Write Enabled」のチェックをオンにする必要があります。

f:id:edo_m18:20180517114150p:plain

そのための処理は以下のようになります。

int width = lightmap.Width;
int height = lightmap.Height;

Vector4 scaleOffset = renderer.lightmapScaleOffset;
Color[] colors = new Color[mesh.uv.Length];

for (int i = 0; i < mesh.uv.Length; i++)
{
    Vector2 uv = mesh.uv[i];
    float uv_x = (uv.x * scaleOffset.x) + scaleOffset.z;
    float uv_y = (uv.y * scaleOffset.y) + scaleOffset.w;
    Color pixel = lightmap.GetPixelBilinear(uv_x, uv_y);
    colors[i] = pixel;
}

mesh.colors = colors;

上記のように、Renderer.lightmapScaleOffsetに、ライトマップデータのオフセット情報が入っています。
それに、メッシュが持つ元々のUV情報と組み合わせてフェッチすることで、目的のライトマップのカラー情報を取得することができます。

今回はメッシュの頂点カラーに焼き込む、というのが目的なので、最終的に取得したピクセル配列をメッシュの頂点カラーとして設定しています。

そして焼き込んだメッシュ情報を利用する場合、アセットとして新規作成しておかないとならないため、今回のサンプルでは元のメッシュを複製、アセットとして保存する処理もあります。

ということで、今回実装した内容全体を下記に記載しておきます。

Meshを生成する

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

public static class BakeLightMap2VC
{
    /// <summary>
    /// Clone mesh asset.
    /// 
    /// If folder in path doesn't exist, will create all folders in path.
    /// </summary>
    public static Mesh CloneMesh(GameObject target)
    {
        if (target == null)
        {
            Debug.LogWarning("Must select a GameObject.");
            return null;
        }

        MeshFilter filter = target.GetComponent<MeshFilter>();
        if (filter == null)
        {
            Debug.LogWarning("CloneMesh need a MeshFilter component.");
            return null;
        }

        Mesh newMesh = GameObject.Instantiate(filter.sharedMesh);

        string[] folderPaths = new[]
        {
            "ClonedMesh",
        };

        string path = "Assets";
        for (int i = 0; i < folderPaths.Length; i++)
        {
            string folderName = folderPaths[i];
            string checkPath = path + "/" + folderName;
            if (!AssetDatabase.IsValidFolder(checkPath))
            {
                AssetDatabase.CreateFolder(path, folderName);
            }
            path = checkPath;
        }

        // Create and save a mesh asset.
        string assetPath = path + "/" + target.name + ".asset";
        string[] findAssets = AssetDatabase.FindAssets(target.name, new[] { path });
        if (findAssets.Length != 0)
        {
            int result = EditorUtility.DisplayDialogComplex("重複チェック", "既存のファイルがあります。上書きしますか?", "はい", "いいえ", "別名で保存");
            if (result == 1)
            {
                return null;
            }
            else if (result == 2)
            {
                assetPath = AssetDatabase.GenerateUniqueAssetPath(assetPath);
            }
        }

        AssetDatabase.CreateAsset(newMesh, assetPath);
        AssetDatabase.SaveAssets();

        EditorGUIUtility.PingObject(newMesh);

        return newMesh;
    }

    /// <summary>
    /// Bake a lightmap shadow to vertex colors.
    /// </summary>
    [MenuItem("Assets/Tools/BakeLightMap2VC")]
    public static void Bake()
    {
        GameObject target = Selection.activeGameObject;
        if (target == null)
        {
            Debug.LogWarning("Must select a GameObject.");
            return;
        }

        // Clone selected object's mesh.
        Mesh clonedMesh = CloneMesh(target);
        if (clonedMesh == null)
        {
            Debug.LogError("Can't clone mesh data.");
            return;
        }

        MeshRenderer renderer = target.GetComponent<MeshRenderer>();
        if (renderer == null)
        {
            Debug.LogWarning("Must have a MeshRenderer.");
            return;
        }
        LightmapData data = LightmapSettings.lightmaps[renderer.lightmapIndex];
        Texture2D lightMap = data.lightmapColor;

        MeshFilter filter = target.GetComponent<MeshFilter>();
        filter.sharedMesh = clonedMesh;

        #region ### Sample LightMap data and bake it to vertex colors ###
        int width = lightMap.width;
        int height = lightMap.height;
        Vector4 scaleOffset = renderer.lightmapScaleOffset;
        Color[] colors = new Color[clonedMesh.uv.Length];
        for (int i = 0; i < clonedMesh.uv.Length; i++)
        {
            Vector2 uv = clonedMesh.uv[i];
            float uv_x = (uv.x * scaleOffset.x) + scaleOffset.z;
            float uv_y = (uv.y * scaleOffset.y) + scaleOffset.w;
            Color pixel = lightMap.GetPixelBilinear(uv_x, uv_y);
            colors[i] = pixel;
        }
        clonedMesh.colors = colors;
        #endregion ### Sampling LightMap data ###
    }
}

頂点カラーを利用するシェーダ

さて最後に。
頂点カラーに情報を書き込んでも、それを読み出さなくては意味がありません。

そして残念ながら、最近の3Dではあまり頂点カラーを使わないため、Unityのデフォルトのシェーダでは頂点カラー情報は処理されません。
なので、頂点カラーを読み出す処理は自分で書く必要があります。

といっても、頂点カラー情報を取得するにはシェーダへの入力に頂点カラーのパラメータを追加するだけです。

struct v2f
{
    fixed4 vertex : SV_POSITION;
    fixed4 color : COLOR; // <- これを追加
    fixed2 uv : TEXCOORD0;
};

あとはこれを、最終出力に乗算してやるだけでOKです。

half4 frag(v2f i) : SV_Target
{
    fixed2 uv = (i.uv * _MainTex_ST.xy) + _MainTex_ST.zw;
    fixed4 col = tex2D(_MainTex, uv);
    return col * i.color * _Color;
}

シェーダ全体も載せておきます。

Shader "VertexShadow" {
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 150
        
        Pass
        {
            Tags { "LightMode"="ForwardBase" "Queue"="Geometry-10" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0
            #include "UnityCG.cginc"

            sampler2D _MainTex;
            fixed4 _MainTex_ST;
            fixed4 _Color;

            struct v2f
            {
                fixed4 vertex : SV_POSITION;
                fixed4 color : COLOR;
                fixed2 uv : TEXCOORD0;
            };

            UNITY_INSTANCING_CBUFFER_START(Props)
            UNITY_INSTANCING_CBUFFER_END

            v2f vert(appdata_full i)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(i.vertex);
                o.color = i.color;
                o.uv = i.texcoord;
                return o;
            }

            half4 frag(v2f i) : SV_Target
            {
                fixed2 uv = (i.uv * _MainTex_ST.xy) + _MainTex_ST.zw;
                fixed4 col = tex2D(_MainTex, uv);
                return col * i.color * _Color;
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

参考

tsubakit1.hateblo.jp

tsubakit1.hateblo.jp

簡易的なiOS向けネイティブプラグインを書いてみる

概要

少し前にAndroid向けのネイティブプラグインを書くための記事を書きました。

edom18.hateblo.jp

edom18.hateblo.jp

今回はiOS向けのプラグインです。
が、今回書くのはごく簡単なプラグイン機能の実装です。

つまりmmファイルを作成してそれを利用するだけの簡単な実装です。
発端はARKitアプリで画面をキャプチャする必要があったのでそのための準備です。

こちらの記事を参考にさせていただきました。

marunouchi-tech.i-studio.co.jp

ネイティブ側の処理を「.mm」ファイルに書く

Plugins/iOS以下にファイルを置く

なにはなくともネイティブ側で動作する処理を書かないとなりません。
UnityではPluginsフォルダ以下にiOSというフォルダを作ることで、自動的にXcodeプロジェクトにファイルがエクスポートされるようになっています。

今回実装した例では以下のようにファイルを配置しています。

f:id:edo_m18:20180430050855p:plain

CaptureCallbackScreenshotPluginプラグイン用のファイルです。今回は.h.mmファイルを配置しています。

今回はこの.mmファイルにネイティブ側の処理を書いていきます。
Xcodeプロジェクトにエクスポートされるため、普通のiOS開発と同じ感覚で処理を記述することができます。(つまりObjective-Cをそのまま書ける)

※ エクスポートされた状態↓
f:id:edo_m18:20180509093631p:plain

参考にさせていただいた記事から引用させてもらうと、以下のような形で書くことでネイティブ側の処理を書くことができます。

Cの関数として定義

// ScreenshotPlugin.mmの一部

extern "C" void _PlaySystemShutterSound()
{
    AudioServicesPlaySystemSound(1108);
}

// ... 後略

Objective-Cも当然使える

// CaptureCallback.h

#import <Foundation/Foundation.h>
 
@interface CaptureCallback : NSObject

@property (nonatomic, copy) NSString *objectName;
@property (nonatomic, copy) NSString *methodName;

- (id)initWithObjectName:(NSString *)_objectName methodName:(NSString *)_methodName;
 
- (void)savingImageIsFinished:(UIImage *)_image didFinishSavingWithError:(NSError *)_error contextInfo:(void *)_contextInfo;
 
@end

要はiOS開発でやっていることをUnity上で管理させてエクスポート、ビルド時に結合する、という形です。
なので、iOS開発をしたことがある人であれば新しいことはほぼないと思います。

Unityとネイティブ側で連携する

さて、ネイティブ側の処理は上記のように記述することで対応できます。
あとは今実装したネイティブ側の処理とUnity側で連携させれば完成です。

作成したネイティブ側の機能を利用するには以下のようにC#で記述します。

public class Screenshot : MonoBehaviour
{
    [DllImport("__Internal")]
    static private extern void _PlaySystemShutterSound();

    // ... 以下略
}

DllImportアトリビュートを使って、そのメソッドが外部で定義されていることを伝えます。
また、static externを付ける必要があります。
関数名とメソッド名が同じになっていることを確認してください。

あとは通常のUnity開発と同様に、上記コードのように宣言だけを行ったメソッドを実行してやればXcodeプロジェクトのビルド時に自動的にリンクが行われ、正常に動作するようになります。

ハマった点

Photos.frameworkを追加する

今回のプラグインではスクリーンショットを撮ってそれをカメラロールに保存する、というものです。
そのためPHPhotoLibraryを使っているのですが、必要なPhotos.frameworkはデフォルトで追加されないので、Xcode上で追加してやるかポストプロセスで自動化する必要があります。

既存のフレームワークを追加するだけなので、以下のようにポストプロセス用のエディタスクリプトを書くだけで簡単に追加することができます。

using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;

public class XcodePostProcess : MonoBehaviour
{
    [PostProcessBuild]
    static public void OnPostProcessBuild(BuildTarget buildTarget, string path)
    {
        if (buildTarget != BuildTarget.iOS)
        {
            return;
        }

        string projPath = PBXProject.GetPBXProjectPath(path);
        PBXProject proj = new PBXProject();
        proj.ReadFromString(File.ReadAllText(projPath));

        string target = proj.TargetGuidByName("Unity-iPhone");

        // フレームワークを追加する
        proj.AddFrameworkToProject(target, "Photos.framework", false);

        // 反映させる
        File.WriteAllText(projPath, proj.WriteToString());
    }
}

NSPhotoLibraryUsageDescription keyを追記する

上記でも書いたように、カメラロールへのアクセスが必要です。
いつかのupdateでiOSでは、明確に、権限を求める理由を記載する必要があります。

UnitySendMessageのためのstring変換

Objective-Cでは通常、stringはNSStringを利用します。
しかし、UnitySendMessageはchar *型を受け取るため、変換が必要です。
また、今回は写真を保存したあとにコールバックを呼ぶ関係で、オブジェクト名をネイティブ側に渡す必要があるため、相互の変換が必要となります。

相互変換

// NSString* -> char*
NSString *hogeStr = @"hoge";
char *hogeChar = [hogeStr UTF8String];

// char* -> NSString*
const char* hogeChar = "hoge";
NSString *hogeStr = [NSString stringWithCString:hogeChar encoding:NSUTF8StringEncoding];

UnitySendMessageを安全に使うために、というこちらの記事も合わせて読んでみてください。

kan-kikuchi.hatenablog.com

Unityでクリッピング平面を実装する

概要

今作っているARアプリでクリッピング平面を作る必要が出てきたのでそのメモです。

動作サンプル↓ f:id:edo_m18:20180508201124g:plain

動画サンプルで動かしたものはGitHubで公開しています。

ちなみに、クリッピング平面自体の説明は以前Qiitaに書いたのでそちらをご覧ください。
今回書くのはARアプリで使用したUnityでのクリッピング平面の実装メモです。

qiita.com

実装

今回、Unityで実装するに当たってクリッピング平面の位置や方向などはC#側で計算し、それをシェーダに渡す形で実装しました。

まずはC#コードを。

C#コード

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ClippingPlanes : MonoBehaviour
{
    [SerializeField]
    private Transform[] _clippingPlanes;

    private Material _material;

    private void Awake()
    {
        _material = GetComponent<MeshRenderer>().material;
    }

    private void Update()
    {
        Vector4[] planes = new Vector4[_clippingPlanes.Length];
        Matrix4x4 viewMatrix = Camera.main.worldToCameraMatrix;

        for (int i = 0; i < planes.Length; i++)
        {
            Vector3 viewUp = viewMatrix.MultiplyVector(_clippingPlanes[i].up);
            Vector3 viewPos = viewMatrix.MultiplyPoint(_clippingPlanes[i].position);
            float distance = Vector3.Dot(viewUp, viewPos);
            planes[i] = new Vector4(viewUp.x, viewUp.y, viewUp.z, distance);
        }

        _material.SetVectorArray("_ClippingPlanes", planes);
    }
}

上記は、平面の方向と距離を算出するコードです。
大事なポイントは3つ。

  1. worldToCameraMatrixがビューマトリクス
  2. worldToCameraMatrix.MultiplyVectorで平面方向をビュー座標に変換
  3. worldToCameraMatrix.MultiplyPointで平面位置を変換、方向ベクトルに射影して距離を得る

です。

そして以下のコードで「平面の方向と距離」をVector4としてシェーダに渡します。

planes[i] = new Vector4(viewUp.x, viewUp.y, viewUp.z, distance);

// ... 中略

_material.SetVectorArray("_ClippingPlanes", planes);

ちなみに、複数プレーンでクリッピングができるようにしてみたのでプレーンの方向と距離は配列でシェーダに渡しています。

シェーダでは_ClippingPlanesを利用してクリッピングを実行します。
シェーダコードは以下です。

Shader "Unlit/ClippingPlane"
{
    Properties
    {
        [Toggle] _Positive("Cull Positive", Float) = 1
        _PlaneCount("Plane count", Range(0, 10)) = 0
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            Cull Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fog
            
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

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

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float _Positive;
            uniform float _PlaneCount;
            uniform float4 _ClippingPlanes[10];
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                o.viewVertex = mul(UNITY_MATRIX_MV, v.vertex);
                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {
                int count = int(_PlaneCount);
                for (int idx = 0; idx < count; idx++)
                {
                    float4 plane = _ClippingPlanes[idx];
                    if (_Positive == 0)
                    {
                        if (dot(plane.xyz, i.viewVertex.xyz) > plane.w)
                        {
                            discard;
                        }
                    }
                    else 
                    {
                        if (dot(plane.xyz, i.viewVertex.xyz) < plane.w)
                        {
                            discard;
                        }
                    }
                }

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

最後に判定部分。
C#から送られてきた、ビュー座標空間での方向と距離を元にビュー座標空間上でのフラグメントの位置を内積を使って計算し、距離が平面より遠かったらクリッピングdiscard)する、という処理です。

また、今回のサンプルでは_PlaneCountで平面の数を指定できるようにしています。
というのもシェーダでは配列を可変にできないため予め領域を確保しておく必要があります。

なので、外部からいくつの平面が渡ってくるかを指定できるようにしている、というわけです。
今回はサンプルなのでひとまず最大10平面まで受け取れるようにしています。

実際問題、クリッピングを実行したい、という平面の数はそこまで多くならないと思います。
もし多数必要になったらテクスチャにして渡すなどある程度工夫が必要でしょう。

が、今回はあくまでクリッピング平面の実装メモなのでそのあたりは適当です。

余談

ちなみにこの判定は面白くて、どこかの質問サイトで見たのが、キャラクターが地面に接地しているか、の判定にまさにこの仕組みを利用していて、なぜこれで接地が判定できるのですか? という質問がありました。

方向と距離を渡して、あとは内積結果と比較するだけで判定が済むので圧倒的な省コストで判定できることになります。
ロジックとしては覚えておくと色々役に立ちそうです。

余談2

ちなみに、これとは別視点で実装した「異空間から現れてた」ような演出をするシェーダについて過去に書いたので興味があったら読んでみてください。

edom18.hateblo.jp

UnityのCompute ShaderでCurl Noiseを実装(衝突判定編)

概要

今回は「衝突判定編」です。
前回の「流体編」の続編です。

edom18.hateblo.jp


さて、今回は論文で発表されたカールノイズの『衝突判定』について書きたいと思います。
ただ、実はまだ正常に実装できておらず、やや課題のある状態での記事ということに留意ください。

ちなみに残している課題として、衝突判定後のパーティクルの挙動が意図したものと若干違う、というものです。

課題の残る状態で実行した結果が以下の動画になります。
(ツイートでは出来たような口ぶりですが、実はちゃんとはうまく動いていませんでした・・・)


本題

カールノイズによる流体のような移動表現については前回書きました。
論文によると、これに加えて剛体などの境界を考慮した動きを実現できるそう。

境界による干渉(論文より抜粋)

境界による干渉について、論文では以下のように書かれています。
原文と、それに対する翻訳(Google翻訳+若干の意訳)を併記します。


境界(Boundaries)

Consider a motionless solid object in the flow. The boundary condition viscous flow must satisfy is \(\vec{v} = 0\).

流れの中に動かないオブジェクトを考えてみる。
その境界条件粘性流は\(\vec{v} = 0\)を満たさなければならない。

This can be achieved simply by modulating the potential down to zero with a smoothed step function of distance, so that all the partial derivatives (and hence the curl) of the new potential are zero at the boundary.

これは、距離の平滑化された段階関数を用いてポテンシャルをゼロに変調することでシンプルに達成できる。
つまり、新しいポテンシャルのすべての偏微分(したがってカール)境界でゼロになる。

Of more interest in animation is the inviscid boundary condition, \(\vec{v} \cdot \vec{n} = 0\), requiring that the component of velocity normal to the boundary is zero.

アニメーションで関心があるのは、非粘性境界条件\((v \cdot n = 0)\)である。
必要なのは、境界に垂直な速度の成分がゼロであることだ。

allowing the fluid to slip past tangentially but not to flow through a solid. Most turbulent fluids have such small viscosities that this is a more reasonable approximation.

流体が接線方向に滑ることを可能にするが、固体を通して流れないようにする。
ほとんどの乱流は、これがより合理的な近似であるような小さな粘性を有する。

In two dimensions, note that our velocity field is just the 90◦ rotation of the gradient ∇ψ: if we want the velocity field to be tangent to the boundary, we need the gradient to be perpendicular to the boundary.

二次元では、速度場は勾配\((\nabla \psi)\)の90°回転に過ぎないことに注意。
速度場を境界線に接したい場合は、境界線に対して垂直な勾配が必要だ。

This happens precisely when the boundary is an isocontour of ψ, i.e. when ψ has some constant value along the boundary.

これは、境界がψの等値であるとき、つまりψが境界に沿ってある一定の値を有するときに正確に起こる。

We can achieve this without eliminating the gradient altogether by modulating ψ with a ramp through zero based on distance to the closest boundary point.

最も近い境界点までの、距離に基いてゼロを通るRampでψを変調することによって、勾配を完全になくすことなくこれを達成できる。

$$ ψ_{constrained}(\vec{x}) = ramp\biggl(\frac{d(\vec{x})}{d_0}\biggr) ψ(\vec{x}) ... (3) $$

where \(d(\vec{x})\) is the distance to all solid boundaries and \(d_0\) is the width of the modified region—when using noise with length scale L, it makes sense to set \(d_0 = L\). We use the following smooth ramp:

\(d(\vec{x})\)は、すべてのソリッド境界までの距離であり、\(d_0\)は修正された領域の幅である。
長さスケール\(L\)のノイズを使用する場合、\(d_0 = L\)に設定することは理にかなっている。
次の、平滑化Ramp関数を利用する:

$$ \begin{eqnarray} ramp(r){=} \begin{cases} 1 & : r \geq 1 \\ \frac{15}{8}r - \frac{10}{8}{r}^3 + \frac{3}{8}{r}^5 &: 1 > r > -1 \\ -1 & : r \leq -1 \end{cases} \end{eqnarray} ... (4) $$

In three dimensions things are a little more complicated.

3次元の場合はもう少し複雑だ。

$$ α = \biggl|ramp\biggl(\frac{d(\vec{x})}{d_0}\biggr)\biggr| $$

\(\vec{n}\) be the normal to the boundary at the closest point to \(\vec{x}\), we use

\(n\) は \(x\) に最も近い点の境界の法線である。
したがって、次の式を使う。

$$ \vec{ψ}_{constrained}(\vec{x}) = α\vec{ψ} (\vec{x}) + (1 − α) \hat{n} (\hat{n} \cdot \vec{ψ} (\vec{x})) ... (5) $$

That is, we ramp down the tangential component of \(\vec{ψ}\) near the boundary, but leave the normal component unchanged. This can be proven to give a tangential velocity field at smooth object boundaries, using the fact that \(\vec{n}\) is the gradient of signed distance from the boundary (hence its curl vanishes).

つまり、\(\vec{\psi}\)の接線成分を境界付近で下降(ramp down)させるが、通常の成分は変化させない。
これは、nが境界からの符号付き距離の勾配であるという事実を利用して、滑らかなオブジェクト境界で接線方向の速度場を与えることが証明される。
(したがってそのカールは消える)

Unfortunately, the normal field may be discontinuous along the medial axis of the geometry: na¨ıvely using equation (5) can result in spikes when we take the curl, particularly near sharp edges.

残念なことに、通常のフィールドは、ジオメトリの内側軸に沿って不連続である可能性がある。
式(5)を使用すると、カールを取るとき、特に鋭いエッジ付近でスパイクが発生する可能性がある。

This isn’t a problem for equation (3) since the distance field is Lipschitz continuous, which is adequate for our purposes.

これは、距離場がLipschitz連続であるため、式(3)の問題ではない。
これは、目的に対して適切である。

Thus within distance \(d_0\) of edges flagged as sharp in the system we default to (3), i.e. drop the normal component.

したがって、システム内でシャープとフラグ立てられたエッジの距離d0内では、デフォルトで(3)になる。
すなわち、通常の成分を落とす。


さて、初見ではなんのこっちゃですが、(自分の浅い理解では)要するに、境界付近では接線方向にのみ速度を持たせてやることで、そちらに流れていくよね、ということを言いたいのだと思います。

そのことを示す式が以下です。

$$ \vec{ψ}_{constrained}(\vec{x}) = α\vec{ψ} (\vec{x}) + (1 − α) \hat{n} (\hat{n} \cdot \vec{ψ} (\vec{x})) ... (5) $$

\(constrained\)(拘束)が示すように、境界付近での速度の方向を拘束し、流れを変えることを表す式です。

もう少し具体的に見てみましょう。
式の右辺を見ると、\(\vec{\psi}(\vec{x})\)に対してなにやら計算しています。

この\(\psi\)はポテンシャル、つまりカールノイズの計算部分です。
その得られた速度ベクトルに対してなにやら計算しているわけです。

式を少し簡単にしてみると、(\(\vec{\psi}(\vec{x})\)を\(P\)と置きます)

$$ αP + (1 - α)P $$

ということです。

つまり、なんらかの係数に応じて徐々にそちらのベクトルを採用する、いわゆる線形補間的な計算ですね。

ただ、右側に置かれているほうはもう少し複雑で、正確には以下です。

$$ (1 − α) \hat{n} (\hat{n} \cdot \vec{ψ} (\vec{x})) $$

ただ、これも分解して考えると、いわゆる「法線方向ベクトルにどれだけ沿っているか」の計算です。
一般的な計算に書き直してみると、

$$ \hat{n}(\hat{n} \cdot \vec{x}) $$

ですね。

コードで表してみる

さて、上記の部分をコードで表したのが以下になります。

境界の法線を計算する

式では、境界付近の法線を利用しています。
そして法線の計算は以下のようになります。

// 勾配(gradient)を計算する
// 基本的な考えは偏微分が法線ベクトルとなることを利用している?
float3 ComputeGradient(float3 p)
{
    const float e = 0.01f;

    // 偏微分するため、各軸の微小値を計算する
    const float3 dx = float3(e, 0, 0);
    const float3 dy = float3(0, e, 0);
    const float3 dz = float3(0, 0, e);

    float d = SampleDistance(p);
    float dfdx = SampleDistance(p + dx) - d;
    float dfdy = SampleDistance(p + dy) - d;
    float dfdz = SampleDistance(p + dz) - d;

    return normalize(float3(dfdx, dfdy, dfdz));
}

// 計算点から、障害物への距離を計算する
float SampleDistance(float3 p)
{
    float3 u = _SphereParam.xyz - p;
    float d = length(u);
    return d - _SphereParam.w;
}

なぜ偏微分したらそれが法線ベクトルになるのかについては以下を参照。

mathtrain.jp

また、距離関数については、(今回は)単純なSphereのみとしているので、比較的シンプルな式で距離計算が出来ています。
その他の距離関数については、こちらのサイトを参照ください。

modeling with distance functions

そして、ポテンシャルの計算を行っているコードが以下。

// α = ramp(d(x)/d0)
// ψ_constrainted(x) = αψ(x) + (1 - α)n(n・ψ(x))
float3 SamplePotential(float3 pos, float time)
{
    float3 normal = ComputeGradient(pos);
    float distance = SampleDistance(pos);

    float3 psi = float3(0, 0, 0);

    const float3 f = float3(2, 0, 2);

    // 高さに応じて乱流の度合いを変化させる(上にいくほど拡散するように)
    float heightFactor = Ramp((pos.y - _PlumeBase) / _PlumeHeight);
    for (int i = 0; i < 3; i++)
    {
        float alpha = abs(Ramp(distance / _NoiseScales[i]));

        float3 s = pos / _NoiseScales[i];

        float3 psi_i = Constraint(Pnoise(s), normal, alpha);
        psi += psi_i * heightFactor * _NoiseGain[i] * (f * sin(time));
    }

    float lScale = 0.1;
    float alpha = abs(Ramp(distance / lScale));
    psi += Constraint(_RisingForce, normal, alpha);

    float3 risingForce = _SphereParam.xyz - pos;
    risingForce = float3(-risingForce.z, 0, risingForce.x);

    // ringの半径?
    // XZ平面の中心からの半径? RingRadius?
    float rr = sqrt(pos.x * pos.x + pos.z * pos.z);
    float temp = sqr(rr - _RingRadius) + sqr(rr + _RingRadius) + _RingFalloff;
    float invSecond = 1.0 / _RingPerSecond;
    float ringY = _PlumeCeiling;
    alpha = Ramp(abs(distance) / _RingRadius);

    // 「煙の柱(Plume)」の下端以下になるまで繰り返す
    while (ringY > _PlumeBase)
    {
        // ringの位置とパーティクルのYの差分
        float ry = pos.y - ringY;

        float b = temp + sqr(ry);
        float rmag = _RingMagnitude / b;

        float3 rpsi = rmag * risingForce;
        psi += Constraint(rpsi, normal, alpha);
        ringY -= _RingSpeed * invSecond;
    }

    return psi;
}

なお、上記コードの乱流生成部分は以下の記事を参考にさせてもらいました。

prideout.net

ただ、(おそらくですが)ここのポテンシャル計算に不備があるせいで、衝突判定がおかしくなっているのかなぁと予測。
パーティクルの動きを見ていると、球体の左右に分かれず、片方にだけ動いていく様子が見えます↓

https://i.gyazo.com/a96df07bdcb7ade2506f9198de8c6704.gif

あるいは、カール(回転)部分の計算に間違いが・・? ただ、そこに関しては色々なカールノイズの実装を参考に何度も確認しましたが、間違いはない・・はず・・。
(このあたり、なにか分かる人いたら教えてほしいです( ;´Д`))

カールノイズを実装したコンピュートシェーダ全文はこちら↓

github.com

論文翻訳メモ

さて、最後に、論文を理解するにあたってちょいちょい(Google翻訳などを利用しながら)翻訳していったものがあるので、せっかくなのでメモとして残しておこうと思います。

※ 注意! いくらかは自分の意訳部分があるので、完全な翻訳ではありません。

論文はこちら(PDF)


​速度は、ポテンシャル(ベクトル場)(\(\vec{ψ}\))に対して回転(curl)をかけたものとして得られる。

$$ \vec{v} = \nabla \times ψ $$

カールされたベクトル場は自動的にダイバージェンスフリーとなる。

A classic vector calculus identity is that the curl of a smooth potential is automatically divergence-free

$$ \nabla \cdot \nabla \times = 0 $$

よって、

$$ \nabla \cdot \vec{v} = 0 $$

パーリンノイズ\(N(\vec{x})\)を使ってベクトル場を形成する。
2Dの場合は、単純に\(ψ=N\)。

To construct a randomly varying velocity field we use Perlin noise \(N(\vec{x})\) in our potential. In 2D, \(]\psi = N\).

3Dの場合は、3要素(ベクトル)が必要となるが、同じノイズ関数を、明らかに大きなオフセットをかけた入力ベクトルを用いて表現する。
普段は、いくつかの異なったスケールを使ったオクターブノイズを乱流の形成のために使う。

In 3D we need three components for the potential: three apparently uncorrelated noise functions (a vector \(\vec{N}(\vec{x})\)) do the job, which in practice can be the same noise function evaluated at large offsets.

$$ \psi(x, t) = A(x)\psi_T(x, t) + ramp\biggl(\frac{d(x)}{d_L}\biggr)\psi_L(x) $$

3Dの場合は、速度ノイズの接線成分のみ、ゼロに減少する。

In 3D, only tangential components of vector noise are ramped down.

シーン内のオブジェクトからゼロに減衰するか、または煙の列の高さに応じて変化させる。

decay to zero away from objects in the scene, or changing it according to the height in a column of smoke.

速度場 \(A(\vec{x})\vec{v}(\vec{x})\) はもはやdivergence-freeを与えないが、それにカールのトリックで成す。

While simply modulating the velocity field \(A(\vec{x})\vec{v}(\vec{x})\) no longer gives a divergnce-free field, modulating the potential, \(\vec{v} = \nabla \times (A(\vec{x})\psi(\vec{x}))\), does the trick.

他のポテンシャル(Other Potentials)

これまでは、動かない境界線に対するノイズだけを構築してきた。
もちろん、既存のプリミティブのフローを速度場に使って、よりリッチな機能を実現することもできる。
そして、ポテンシャルをより高めることができる。

So far we have only constructed noise that respects unmoving solid boundaries. While of course we can superimpose existing flow primitives on the velocity field for richer capabilities, we can also do more with the potential itself.

線形速度\(V\)と角速度\(ω\)を持つRigidbodyに対応するポテンシャルを導出することはシンプルにできる。

It is simple to derive a potential corresponding to a rigid body motion with linear velocity \(\vec{V}\) and angular velocity \(\vec{ω}\) :

$$ \vec{\psi}_{rigid}(\vec{x}) = \vec{V} \times (\vec{x} − \vec{x_0}) + \frac{{R}^2 - {|| \vec{x} − \vec{x_0} ||}^2}{2} \vec{ω} $$

ここで、\(\vec{x_0}\)は任意の参照点であり、\(R\)は任意の参照レベルを表す。

where \({x_0}\) is an arbitrary reference point and R is an arbitrary reference level.

Note:
同じ\(v\)に対応する無限に多くのポテンシャルが常に存在することに気をつける。
スカラー場の勾配だけが異なるふたつのポテンシャルは、同じカールを有する。

Note that there are always infinitely many potentials corresponding to the same \(\vec{v}\): any two potentials which differ by only the gradient of some scalar field have exactly the same curl.

動くRigidbodyの境界条件を満たすように修正したポテンシャル\(\vec{\psi}\)を仮定する。
まず、物体の表面上速度の法線成分をゼロにするために方程式(5)を使用し、\(\vec{\psi_0}\)を与える。
次に、\(x_0\)を剛体の中心として選択した式(6)を使用し、オブジェクトからゼロに滑らかにブレンドする。
(\(R\)をブレンド領域の半径とすると、ブレンドされた回転項が単調に0に落ちる)

Suppose we have a potential \(\vec{ψ}\) we wish to modify to respect boundary conditions on a moving rigid object. First we use equation (5) to zero out the normal component of velocity on the object’s surface, giving \(\vec{ψ_0}\). Then we use equation (6) with \(x_0\) chosen, say, as the center of the rigid body, smoothly blend it to zero away from the object (choosing R to be the radius of the blend region, so that the blended rotational term drops monotonically to zero),

それを加えて

$$ \vec{ψ} (\vec{x}) = \vec{ψ}_0 + A(\vec{x})\vec{ψ}_{rigid}(\vec{x}) $$

を得る。

例えば、各剛体への逆二乗距離に基づくブレンド関数\(A(\vec{x})\)の実験を行った。

We have experimented, for example, with a blending function \(A(\vec{x})\) based on inverse squared distance to each rigid body.

剛体の境界では、速度は剛体モーションと、表面に接するベクトル場の合計となる。
これは構成上、非粘性境界条件を尊重する。

Note that at the boundary of the rigid object, the velocity is the sum of the rigid motion and a vector field tangent to the surface: by construction this respects the inviscid boundary condition.

単一のボディにてついては参照点は任意だが、複数のボディがある場合は同じ参照点を使用してブレンドをよりよくするのに役立つ。
特に、すべてのボディが同じ剛体モーションで動いている場合は、そのポテンシャルを合わせてブレンドを完璧にしたいと考えている。

While for a single body the reference point is arbitrary, if we have multiple bodies it can help to use the same reference point to make the blend better. In particular, if all bodies are moving with the same rigid motion, then of course we want their potentials to match up to make the blend perfect.

変形する物体の周りの流れ場はよりトリッキーである。
閉じた変形表面の場合、もしボディがその体積を保持しなければ不可能である。
したがって、将来の作業のためにそれは残しておく。

Flow fields around deforming bodies are trickier. For a closed deforming surface, it may even be impossible—if the body doesn’t conserve its volume—and thus we leave that for future work.

いくつかのプリミティブな渦(Vortex)は、\(\vec{x}\)位置の角速度\(\vec{ω}\)、半径\(R\)、および平滑化フォールオフ関数を有する単純なパーティクルを含む。

Some vortex primitives include a simple vortex particle at \(\vec{x_0}\) with angular velocity \(\vec{ω}\) , radius \(R\), and smooth fall-off function \(f\) :

$$ \vec{\psi}_{vort}(\vec{x}) = f\Biggl(\frac{||\vec{x} − \vec{x_0}||}{R}\Biggr) \frac{{R}^2 − ||\vec{x} − \vec{x_0}||^2}{2} \vec{ω} ... (7) $$

そして、スモーク・リングやプルーム(Plumes)に役立つ、単純な渦カーブ:

and a simple vortex curve, useful for smoke rings and plumes:

$$ \vec{\psi}_{curve}(\vec{x}) = f\Biggl(\frac{||\vec{x} − \vec{x_C}||}{R}\Biggr) \frac{{R}^2 − ||\vec{x} − \vec{x_C}||^2}{2} \vec{ω_C} ... (8) $$

\(\vec{x_C}\)は\(\vec{x}\)までの、曲線上の最も近い点であり、\(\vec{ω_C}\)は角速度(曲線に接する)である。

他の興味深いフロー構造も、剛体運動式を念頭に置いて同様に作成することができる。

where \(\vec{x_C}\) is the closest point on the curve to \(\vec{x}\), and \(\vec{ω}\) C is the angular velocity (tangent to the curve). Other interesting flow structures can be similarly created with the rigid motion formulas in mind.

備考メモ

論文に書かれている備考部分に対するメモです。(あくまで個人的な解釈です)

f:id:edo_m18:20180111134000p:plain
↑論文に添えられていた図

左手側は、剛体の背後に乱流を起こしたもの。右手側は、流体を後ろに押し出したあと渦輪を設定したもの。(あってる?)

どちらのケースも、乱流ノイズの各オクターブは、ジオメトリに対してスケールに適した方法で調整される。

$$ \psi_t(x) = \sum_i a_i ramp\left(\frac{d(x)}{d_i}\right) N\left(\frac{x}{d_i}, \frac{t}{d_i}\right) $$

そして、障害物の背後にのみ生成した乱流に平滑化された振幅関数を乗算する。
これを、基礎の層流(lamninar(ラミナ))に加える。これも、境界付近でスムーズにゼロにランプされる。

※ 流体の流れは2つに分けられ、流体部分が秩序正しく流れる場合を層流と呼び、不規則に混合しながら流れる場合を乱流と呼ぶ。
出典: https://www.weblio.jp/content/laminar+flow

$$ \psi(x, t) = A(x) \psi_T(x, t) + ramp\left(\frac{d(x)}{d_L}\right) \psi_L(x) $$

個人的見解

以下は、論文を読んでいくにあたって、記号など「こういう意味かなー」と思ったことのメモです。
記号や単語の意味を類推して読んでいくと意外とすっと頭に入ってくることもあるので。

\(\psi_T(x)\)が、乱流(Trubulent)を表す式。(なので\(T\))
それを、ジオメトリに応じてAdjustする変数\(a\)。
これが「乱流」部分になるので、それを、基礎層流(Laminar Flow)に加える、ということになる。
(という意味かな?)

そして、基礎層流を表すのが

$$ ramp\left(\frac{d(x)}{d_L}\right) \psi_L(x) $$

の式。

仮にこれを\(L\)と置き、さらに乱流を\(T\)と置くと、最後の式は

$$ \psi(x, t) = A(x) T + L $$

と書ける。

この\(A(x)\)が、文章で書かれている「We then multiply by a smooth AMPLITUDE function」のことだと思う。

ただ、平滑化された振幅関数ってどう表現するのだろう・・。ひとまず適当にSinとか掛けておけばいいんだろうか。
なんとなく、Sinを使った値を加算してみたら煙が立ち上る感じに見えたので、多分そんな感じなんだと思うw

その他、役立ちそうなリンク

prideout.net

catlikecoding.com

github.com

リンクではありませんが、MacにはGrapherというアプリが標準でついていて、以下のキャプチャのように、数式を入れるとそれを可視化してくれる、というものがあります。

数式だけだと一体なにをしているんだ? となることが多いですが、視覚化されると「ああ、なるほど。こういう形を求めているのだな」と分かることが少なくないので、利用して色々な数式を視覚化してみることをオススメします。

f:id:edo_m18:20171210134609p:plain

TransformクラスのTransform**** / InverseTransform****系メソッドのメモ

以前、Qiitaで書いていた記事の移植です。
Inverse系も追加したのでこちらに移動しました。

概要

TransformPointTransformVectorTransformDirectionがそれぞれなにをやってくれているのか、ピンと来てなかったので、どういう処理をしたら同じ結果が得られるのか書いてみたのでメモ。

まぁやってることはシンプルに、モデル行列掛けているだけだけど、その後の処理で平行移動を無効化したり正規化したり、といったことをしている。方向だけを取りたい、みたいなときに使う。
なにやってるか分からないと気持ち悪かったので書いてみた、完全にメモですw

Transform****系メソッド

// ----------------------------------------------------------------------------
// TransformPoint
// 意味: 通常のモデル行列を掛けたポイントを得る
Vector3 point = Vector3.one;
Vector3 newPoint1 = transform.TransformPoint(point);

Vector4 v = new Vector4(point.x, point.y, point.z, 1f);
Vector3 newPoint2 = transform.localToWorldMatrix * v;

// ↓Vector4作らないで済むのでこちらのほうが便利
// Vector3 newPoint2 = transform.localToWorldMatrix.MultiplyPoint(point);

// ----------------------------------------------------------------------------
// TransformVector
// 意味: 回転を適用したのちに、平行移動を無効化したベクトルを得る
Vector3 vector = new Vector3(0, 1.5f, 0);
Vector3 newVector1 = transform.TransformVector(vector);

Vector4 v = new Vector4(vector.x, vector.y, vector.z, 1f);
Vector3 newVector2 = (transform.localToWorldMatrix * v) - transform.position;

// ↓Vector4作らないで済むのでこちらのほうが便利
// Vector3 newVector2 = transform.localToWorldMatrix.MultiplyVector(vector);

// ----------------------------------------------------------------------------
// TransformDirection
// 意味: 回転を適用したのちに、平行移動を無効化し正規化したベクトルを得る
Vector3 direction = Vector3.up;
Vector3 newDirection1 = transform.TransformDirection(direction);

Vector4 v = new Vector4(direction.x, direction.y, direction.z, 1f);
Vector3 newDirection2 = ((transform.localToWorldMatrix * v) - transform.position).normalized;

// ↓Vector4作らないで済むのでこちらのほうが便利
// Vector3 newDirection2 = transform.localToWorldMatrix.MultiplyVector(direction);

InverseTransform****系メソッド

// ----------------------------------------------------------------------------
// InverseTransformPoint
// 意味: ワールド座標からローカル座標へ変換する

Vector3 newPoint1 = transform.InverseTransformPoint(_v);

Matrix4x4 m = transform.worldToLocalMatrix;
Vector4 p = new Vector4(_v.x, _v.y, _v.z, 1f);
Vector3 newPoint2 = m * p;


// ----------------------------------------------------------------------------
// InverseTransformVector
// 意味: 指定したベクトルの方向のみ、ワールド座標からローカル座標へ変換する

Vector3 newdir1 = transform.InverseTransformVector(_v);

Matrix4x4 m = transform.worldToLocalMatrix;
Vector4 c0 = m.GetColumn(0);
Vector4 c1 = m.GetColumn(1);
Vector4 c2 = m.GetColumn(2);
Vector4 c3 = new Vector4(0, 0, 0, 1f);
Matrix4x4 newM = new Matrix4x4(c0, c1, c2, c3);
newM = newM.inverse.transpose;

Vector4 p = new Vector4(_v.x, _v.y, _v.z, 1f);
Vector3 newdir2 = newM * p;

// ↓内容的には以下でまかなえる
// Vector3 newdir2 = transform.worldToLocalMatrix.MultiplyVector(_v);


// ----------------------------------------------------------------------------
// InverseTransformDirection
//
// ※ InverseTransformVectorの正規化した単位ベクトルを得るのかと思いきや、InverseTransformVectorと同じだったので割愛

解説

Inverse系のVector処理はやや複雑です。抜粋するとコードは以下。

Matrix4x4 m = transform.worldToLocalMatrix;
Vector4 c0 = m.GetColumn(0);
Vector4 c1 = m.GetColumn(1);
Vector4 c2 = m.GetColumn(2);
Vector4 c3 = new Vector4(0, 0, 0, 1f);
Matrix4x4 newM = new Matrix4x4(c0, c1, c2, c3);
newM = newM.inverse.transpose;

取り出した、ワールドからローカルへの変換行列をさらに分解して新しい行列を生成しています。
ここでやっていることは方向のみのベクトルを扱う関係で平行移動部分を除去し、移動なしの行列に変換しています。

そして最後に、逆転置行列にするためにnewM = newM.inverse.transpose;としています。

なぜ逆転置行列なのか。
それには理由があります。詳細については以下の記事がとても詳しいのでそちらを御覧ください。

raytracing.hatenablog.com

言葉で説明している箇所を引用させていただくと、

頂点について、モデル座標系からワールド座標系に移すときには何らかの変換行列を使って変換する。
しかし、頂点に対して作用する行列と同じものを法線に対して作用させてもうまくいかない。 まず、平行移動成分を除去しなければならない。法線は向きのみの量なので平行移動は関係ないからである。
回転移動成分についてはそのまま使うことが出来る。
拡大縮小などのスケーリング成分については、たとえば各頂点をX軸方向に3倍にする場合、法線はX軸方向に1/3倍にする必要がある。(最終的に正規化して長さは1にする)

これらの各要素を考慮しつつ法線を適切に変換するための行列を求めるには、頂点の変換行列の逆転置行列を求めればよい、ということが知られている。

ということ。
実際にベクトルを書いてみると分かりますが、スケールを掛けると方向が異なった方向を向いてしまいます。
しかし今回の求めるべきベクトルは方向を適切に変換することにあります。

なので上記のように「逆転置行列」を使う必要がある、というわけです。

TransformのLookAtをUnity APIを使わずに書く

概要

仕事でLookAtを、Unity APIを使わずに書くことがあったのでメモ。

実行した結果。ちゃんとCubeを注視し続けます。
f:id:edo_m18:20180418015908g:plain

サンプルコード

実装したサンプルコードは以下。

このスクリプトをカメラに貼り付けて、ターゲットを指定すると常にそれを視界に捉えた状態になります。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class LookAtSample : MonoBehaviour
{
    [SerializeField]
    private Transform _target;
    
    private void Update()
    {
        LookAt(_target.position);
    }

    private void LookAt(Vector3 target)
    {
        Vector3 z = (target - transform.position).normalized;
        Vector3 x = Vector3.Cross(Vector3.up, z).normalized;
        Vector3 y = Vector3.Cross(z, x).normalized;

        Matrix4x4 m = Matrix4x4.identity;
        m[0, 0] = x.x; m[0, 1] = y.x; m[0, 2] = z.x;
        m[1, 0] = x.y; m[1, 1] = y.y; m[1, 2] = z.y;
        m[2, 0] = x.z; m[2, 1] = y.z; m[2, 2] = z.z;

        Quaternion rot = GetRotation(m);
        transform.rotation = rot;
    }

    private Quaternion GetRotation(Matrix4x4 m)
    {
        float[] elem = new float[4];
        elem[0] = m.m00 - m.m11 - m.m22 + 1.0f;
        elem[1] = -m.m00 + m.m11 - m.m22 + 1.0f;
        elem[2] = -m.m00 - m.m11 + m.m22 + 1.0f;
        elem[3] = m.m00 + m.m11 + m.m22 + 1.0f;

        int biggestIdx = 0;
        for (int i = 0; i < elem.Length; i++)
        {
            if (elem[i] > elem[biggestIdx])
            {
                biggestIdx = i;
            }
        }

        if (elem[biggestIdx] < 0)
        {
            Debug.Log("Wrong matrix.");
            return new Quaternion();
        }

        float[] q = new float[4];
        float v = Mathf.Sqrt(elem[biggestIdx]) * 0.5f;
        q[biggestIdx] = v;
        float mult = 0.25f / v;

        switch (biggestIdx)
        {
            case 0:
                q[1] = (m.m10 + m.m01) * mult;
                q[2] = (m.m02 + m.m20) * mult;
                q[3] = (m.m21 - m.m12) * mult;
                break;
            case 1:
                q[0] = (m.m10 + m.m01) * mult;
                q[2] = (m.m21 + m.m12) * mult;
                q[3] = (m.m02 - m.m20) * mult;
                break;
            case 2:
                q[0] = (m.m02 + m.m20) * mult;
                q[1] = (m.m21 + m.m12) * mult;
                q[3] = (m.m10 - m.m01) * mult;
                break;
            case 3:
                q[0] = (m.m21 - m.m12) * mult;
                q[1] = (m.m02 - m.m20) * mult;
                q[2] = (m.m10 - m.m01) * mult;
                break;
        }

        return new Quaternion(q[0], q[1], q[2], q[3]);
    }
}

LookAtの行列を作る

LookAtは結局のところ、ターゲットに向く「回転」を作ることと同義です。

上のコードでやっていることはシンプルです。
「向かせたい方向のベクトル」と「空方向のベクトル」から、外積を使ってX軸を割り出します。
(空方向と垂直なベクトルはX軸ですよね)

そして向かせたい方向ベクトルがZ軸となるわけなので、結果として、割り出したX軸と向かせたいベクトルとの外積を取ることで最終的な「上方向」のベクトルを得ることができます。
(「上」が空方向とは限らない)

そうやって求めた3軸の各要素を元に行列を作ります。
なぜこれが「LookAt」に相当するのか。

(x, y, z)(1, 1, 1)のときを考えてみましょう。
該当のコードは以下です。

private void LookAt(Vector3 target)
{
    Vector3 z = (target - transform.position).normalized;
    Vector3 x = Vector3.Cross(Vector3.up, z).normalized;
    Vector3 y = Vector3.Cross(z, x).normalized;

    Matrix4x4 m = Matrix4x4.identity;
    m[0, 0] = x.x; m[0, 1] = y.x; m[0, 2] = z.x;
    m[1, 0] = x.y; m[1, 1] = y.y; m[1, 2] = z.y;
    m[2, 0] = x.z; m[2, 1] = y.z; m[2, 2] = z.z;

    Quaternion rot = GetRotation(m);
    transform.rotation = rot;
}

そして今作った行列をそのベクトル(1, 1, 1)に適用してみます。
Unityの行列は列オーダーなので、ベクトルを右から掛けることで計算します。
つまり、

$$ \begin{vmatrix} x.x &y.x &z.x \\ x.y &y.y &z.y \\ x.z &y.z &z.z \\ \end{vmatrix} \times \begin{vmatrix} 1 \\ 1 \\ 1 \\ \end{vmatrix}= \begin{vmatrix} x.x &y.x &z.x \end{vmatrix} $$

となって、回転後の値になっているのが分かるかと思います。

回転行列からクォータニオンを計算する

さて、無事に回転行列が作れましたが、Unityでは回転をクォータニオンで扱っています。
そのため、求めた回転行列はそのまま使えません。なので、クォータニオンに変換する必要があります。

回転行列からクォータニオンへの変換は、マルペケさんのこちらの記事(その58 やっぱり欲しい回転行列⇔クォータニオン相互変換)を参考にさせていただきました。

コード部分は以下です。

private Quaternion GetRotation(Matrix4x4 m)
{
    float[] elem = new float[4];
    elem[0] = m.m00 - m.m11 - m.m22 + 1.0f;
    elem[1] = -m.m00 + m.m11 - m.m22 + 1.0f;
    elem[2] = -m.m00 - m.m11 + m.m22 + 1.0f;
    elem[3] = m.m00 + m.m11 + m.m22 + 1.0f;

    int biggestIdx = 0;
    for (int i = 0; i < elem.Length; i++)
    {
        if (elem[i] > elem[biggestIdx])
        {
            biggestIdx = i;
        }
    }

    if (elem[biggestIdx] < 0)
    {
        Debug.Log("Wrong matrix.");
        return new Quaternion();
    }

    float[] q = new float[4];
    float v = Mathf.Sqrt(elem[biggestIdx]) * 0.5f;
    q[biggestIdx] = v;
    float mult = 0.25f / v;

    switch (biggestIdx)
    {
        case 0:
            q[1] = (m.m10 + m.m01) * mult;
            q[2] = (m.m02 + m.m20) * mult;
            q[3] = (m.m21 - m.m12) * mult;
            break;
        case 1:
            q[0] = (m.m10 + m.m01) * mult;
            q[2] = (m.m21 + m.m12) * mult;
            q[3] = (m.m02 - m.m20) * mult;
            break;
        case 2:
            q[0] = (m.m02 + m.m20) * mult;
            q[1] = (m.m21 + m.m12) * mult;
            q[3] = (m.m10 - m.m01) * mult;
            break;
        case 3:
            q[0] = (m.m21 - m.m12) * mult;
            q[1] = (m.m02 - m.m20) * mult;
            q[2] = (m.m10 - m.m01) * mult;
            break;
    }

    return new Quaternion(q[0], q[1], q[2], q[3]);
}

詳細についてはマルペケさんの記事をご覧ください。

ざっくり概要だけを書くと、回転行列の各要素はクォータニオンから回転行列に変換する際に「どの要素を使うか」で求めることができます。
マルペケさんで書かれている行列を引用させていただくと、以下のようになります。

$$ \begin{vmatrix} 1 - 2y^2 - 2z^2 &2xy + 2wz & 2xz - 2wy &0 \\ 2xy - 2wz &1 - 2x^2 - 2z^2 &2yz + 2wx &0 \\ 2xz + 2wy &2yz - 2wx &1 - 2x^2 - 2y^2 &0 \\ 0 &0 &0 &1 \end{vmatrix} $$

ここで使われているx, y, z, wクォータニオンの各成分です。

そして、この値を組み合わせることによって目的のx, y, z, wを求める、というのが目標です。
例えば、以下の回転行列の要素を組み合わせることで値を取り出します。

$$ m_{11} + m_{22} + m_{33} = 3 - 4(x^2 + y^2 + z^2) \\ = 3 - 4(n_x^2 + n_y^2 + n_z^2) \sin^2 \theta ' \\ = 3 - 4(1 - \cos^2 \theta ' \\ = 4w^2 - 1 $$

となります。
ここで、\(\theta ' = \frac{\theta}{2}\)なので、wの式に直すと以下のようになります。

$$ w = \frac{\sqrt{m_{11} + m_{22} + m_{33} + 1}}{2} $$

他の値も同様にして計算していきます。
これ以外にも色々考慮しないとならないことがありますが、詳細についてはマルペケさんの記事をご覧ください。

こうして求めたクォータニオンを、該当オブジェクトのrotationに設定することで、冒頭の画像のようにLookAtが完成します。