Help us understand the problem. What is going on with this article?

極座標(球面座標)のインスペクタを作ると VR音ゲー作るときに死ぬほど便利だった

More than 3 years have passed since last update.

はじめに

こないだコミティアで Cardboard で遊べる VR 音ゲーを展示しました。それを作っている時にノートを3次元空間に置かないといけなかったんですが、その時に極座標(球面座標)のインスペクタを作ったらとても便利だったのでシェアします。

問題

ノードを3次元空間に置くとき、プレイヤーの考える右側とワールド座標軸で考える右というのがずれてしまう。

矢印が人で○がノートだった場合

  ○

  ↑

普通、右のほうに○が来てほしいというとこういうのを想像するはず。

 
 
  ↑   ○

でも、Unity で x 軸方向に動かすとこうなってしまう。

                         ○

  ↑

これはイメージと違う!永遠に右にきてくれない!

困ったこと

  • ノート配置してちょっと右側にずらしたいって思っても、2軸の値を調節しないといけない
  • プレイヤーからの距離を維持したままノートを移動できない
    • つまり距離の違いからノートが小さくなったり大きくなったりしてしまう

解決策

こういうときには球面座標というのを使えばいいらしい!球面座標というのは、ある特定の点から右にp度、上にe度の方向に、a進んだ点というのを表すことができる。詳しいことはぐぐってください。

そうして、それを元に Transform のカスタムインスペクタを作りました。

image

これを使うと、メインカメラへの角度を変えずに距離だけ変えたり、距離を変えずに周囲を移動させることができるようになります。超雑に複数オブジェクトの編集やアンドゥにも対応させてあります。

コード

球面座標の実装は Spherical coordinates in Unity からお借りしました。

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

[CustomEditor(typeof(Transform))]
[CanEditMultipleObjects]
public class TransformEditor : Editor
{
    SerializedProperty localPositionProp;
    SerializedProperty localRotationProp;
    SerializedProperty localScaleProp;

    Vector3 CameraPos { get { return Camera.main.transform.position; } }

    void OnEnable()
    {
        localPositionProp = serializedObject.FindProperty("m_LocalPosition");
        localRotationProp = serializedObject.FindProperty("m_LocalRotation");
        localScaleProp = serializedObject.FindProperty("m_LocalScale");
    }

    public override void OnInspectorGUI()
    {
        serializedObject.Update();

        DrawLocalPosition();
        DrawLocalEular();
        DrawLocalScale();

        DrawSphericalEditor();

        serializedObject.ApplyModifiedProperties();
    }

    void RegisterUndo(IEnumerable<UnityEngine.Object> targets, string name)
    {
        Undo.RecordObjects(targets.ToArray(), name);
        foreach (var obj in targets)
        {
            EditorUtility.SetDirty(obj);
        }
    }

    void DrawLocalPosition()
    {
        EditorGUILayout.BeginHorizontal();
        if (GUILayout.Button("\u2600", GUILayout.Width(30))) localPositionProp.vector3Value = Vector3.zero;
        localPositionProp.vector3Value = EditorGUILayout.Vector3Field("位置", localPositionProp.vector3Value);
        EditorGUILayout.EndHorizontal();
    }

    void DrawLocalEular()
    {
        EditorGUILayout.BeginHorizontal();
        var localEular = localRotationProp.quaternionValue.eulerAngles;
        var originalEular = localEular;
        if (GUILayout.Button("\u2600", GUILayout.Width(30))) localEular = Vector3.zero;
        localEular = EditorGUILayout.Vector3Field("回転", localEular);

        if (!AreEqual(localEular, originalEular))
        {
            localRotationProp.quaternionValue = Quaternion.Euler(localEular);
        }
        EditorGUILayout.EndHorizontal();
    }

    void DrawLocalScale()
    {
        EditorGUILayout.BeginHorizontal();
        if (GUILayout.Button("\u2600", GUILayout.Width(30))) localScaleProp.vector3Value = Vector3.one;
        localScaleProp.vector3Value = EditorGUILayout.Vector3Field("スケール", localScaleProp.vector3Value);
        EditorGUILayout.EndHorizontal();
    }

    void DrawSphericalEditor()
    {
        if (Camera.main == null) return;
        if (Camera.main.gameObject.GetComponentsInParent<Transform>().Any(tr => tr == target)) return;

        DrawSphericalCoordinate();

        if (serializedObject.isEditingMultipleObjects)
        {
            EditorGUILayout.BeginHorizontal();
            DrawRadiusDistributing();
            DrawRadiusAligning();
            DrawPolarDistributing();
            DrawPolarAligning();
            DrawElevationDistributing();
            DrawElevationAligning();
            EditorGUILayout.EndHorizontal();
        }

        EditorGUILayout.BeginHorizontal();
        DrawLookAtCamera();
        EditorGUILayout.EndHorizontal();
    }

    void DrawSphericalCoordinate()
    {
        var transform = (Transform)target;
        var worldPos = transform.position;

        var spherical = new SphericalCoordinate(worldPos - CameraPos);

        EditorGUILayout.BeginHorizontal();
        EditorGUIUtility.labelWidth = 30;
        GUILayout.Label("カメラ極座標");
        var newRadius = EditorGUILayout.FloatField("半径", spherical.Radius);
        var newPolar = EditorGUILayout.FloatField("横", spherical.Polar);
        var newElevation = EditorGUILayout.FloatField("縦", spherical.Elevation);
        EditorGUILayout.EndHorizontal();

        var radiusDiff = newRadius - spherical.Radius;
        var polarDiff = newPolar - spherical.Polar;
        var elevationDiff = newElevation - spherical.Elevation;

        if (!AreEqual(0, radiusDiff) || !AreEqual(0, polarDiff) || !AreEqual(0, elevationDiff))
        {
            RegisterUndo(targets, "極座標移動");

            foreach (Transform targetTransform in targets)
            {
                var targetSpherical = new SphericalCoordinate(targetTransform.position - CameraPos);
                targetSpherical.Radius += radiusDiff;
                targetSpherical.Polar += polarDiff;
                targetSpherical.Elevation += elevationDiff;

                targetTransform.position = targetSpherical.ToCartesian() + CameraPos;
            }
        }
    }

    void DrawRadiusDistributing()
    {
        if (GUILayout.Button("半径を等分"))
        {
            RegisterUndo(targets, "半径を等分");

            var orderedTransforms = targets.OfType<Transform>()
                                        .Select(tr => new SphericalTransform(tr.position - CameraPos, tr))
                                        .OrderBy(st => st.Radius)
                                        .Select((st, i) => new OrderedSphericalTransform(st, i));

            var distanceBetweenFrontToEnd = orderedTransforms.Last().Radius - orderedTransforms.First().Radius;
            var distanceBetweenObjs = distanceBetweenFrontToEnd / (orderedTransforms.Count() - 1);
            var mostFrontRadius = orderedTransforms.First().Radius;

            foreach (var tr in orderedTransforms)
            {
                var spherical = tr.Spherical;
                spherical.Radius = mostFrontRadius + distanceBetweenObjs * tr.Order;
                tr.Transform.position = spherical.ToCartesian() + CameraPos;
            }
        }
    }

    void DrawRadiusAligning()
    {
        if (GUILayout.Button("半径を整列"))
        {
            RegisterUndo(targets, "半径を整列");

            var orderedTransforms = targets.OfType<Transform>()
                                           .Select(tr => new SphericalTransform(tr.position - CameraPos, tr))
                                           .OrderBy(st => st.Radius);


            var averagedRadius = orderedTransforms.Average(tr => tr.Radius);
            foreach (var tr in orderedTransforms)
            {
                var spherical = tr.Spherical;
                spherical.Radius = averagedRadius;
                tr.Transform.position = spherical.ToCartesian() + CameraPos;
            }
        }
    }

    void DrawPolarDistributing()
    {
        if (GUILayout.Button("横を等分"))
        {
            RegisterUndo(targets, "横を等分");

            var orderedTransforms = targets.OfType<Transform>()
                                        .Select(tr => new SphericalTransform(tr.position - CameraPos, tr))
                                        .OrderBy(st => st.Polar)
                                        .Select((st, i) => new OrderedSphericalTransform(st, i));

            var distanceBetweenMinMax = orderedTransforms.Last().Polar - orderedTransforms.First().Polar;
            var distanceBetweenObjs = distanceBetweenMinMax / (orderedTransforms.Count() - 1);
            var minPolar = orderedTransforms.First().Polar;

            foreach (var tr in orderedTransforms)
            {
                var spherical = tr.Spherical;
                spherical.Polar = minPolar + distanceBetweenObjs * tr.Order;
                tr.Transform.position = spherical.ToCartesian() + CameraPos;
            }
        }
    }

    void DrawPolarAligning()
    {
        if (GUILayout.Button("横を整列"))
        {
            RegisterUndo(targets, "横を整列");

            var orderedTransforms = targets.OfType<Transform>()
                                           .Select(tr => new SphericalTransform(tr.position - CameraPos, tr))
                                           .OrderBy(st => st.Polar);

            var averagedPolar = orderedTransforms.Average(tr => tr.Polar);
            foreach (var tr in orderedTransforms)
            {
                var spherical = tr.Spherical;
                spherical.Polar = averagedPolar;
                tr.Transform.position = spherical.ToCartesian() + CameraPos;
            }
        }
    }

    void DrawElevationDistributing()
    {
        if (GUILayout.Button("縦を等分"))
        {
            RegisterUndo(targets, "縦を等分");

            var orderedTransforms = targets.OfType<Transform>()
                                        .Select(tr => new SphericalTransform(tr.position - CameraPos, tr))
                                        .OrderBy(st => st.Elevation)
                                        .Select((st, i) => new OrderedSphericalTransform(st, i));

            var distanceBetweenMinMax = orderedTransforms.Last().Elevation - orderedTransforms.First().Elevation;
            var distanceBetweenObjs = distanceBetweenMinMax / (orderedTransforms.Count() - 1);
            var minElevation = orderedTransforms.First().Elevation;

            foreach (var tr in orderedTransforms)
            {
                var spherical = tr.Spherical;
                spherical.Elevation = minElevation + distanceBetweenObjs * tr.Order;
                tr.Transform.position = spherical.ToCartesian() + CameraPos;
            }
        }
    }

    void DrawElevationAligning()
    {
        if (GUILayout.Button("縦を整列"))
        {
            RegisterUndo(targets, "縦を整列");

            var orderedTransforms = targets.OfType<Transform>()
                                           .Select(tr => new SphericalTransform(tr.position - CameraPos, tr))
                                           .OrderBy(st => st.Elevation);

            var averagedElevation = orderedTransforms.Average(tr => tr.Polar);
            foreach (var tr in orderedTransforms)
            {
                var spherical = tr.Spherical;
                spherical.Elevation = averagedElevation;
                tr.Transform.position = spherical.ToCartesian() + CameraPos;
            }
        }
    }

    void DrawLookAtCamera()
    {
        if (GUILayout.Button("カメラに向ける"))
        {
            RegisterUndo(targets, "カメラに向ける");

            foreach (Transform t in targets)
            {
                t.LookAt(CameraPos);
            }
        }
    }


    bool AreEqual(Vector3 a, Vector3 b)
    {
        if (!AreEqual(a.x, b.x)) return false;
        if (!AreEqual(a.y, b.y)) return false;
        if (!AreEqual(a.z, b.z)) return false;
        return true;
    }

    bool AreEqual(float a, float b)
    {
        if (Math.Abs(a - b) > 0.0001) return false;
        return true;
    }

    struct SphericalCoordinate
    {
        public float Radius;
        public float Polar;
        public float Elevation;

        public SphericalCoordinate(Vector3 cartesianCoordinate)
        {
            CartesianToSpherical(cartesianCoordinate, out Radius, out Polar, out Elevation);
        }

        public SphericalCoordinate(float radius, float polar, float elevation)
        {
            Radius = radius;
            Polar = polar;
            Elevation = elevation;
        }

        public Vector3 ToCartesian()
        {
            return SphericalToCartesian(Radius, Polar, Elevation);
        }

        // http://blog.nobel-joergensen.com/2010/10/22/spherical-coordinates-in-unity/
        static Vector3 SphericalToCartesian(float radius, float polar, float elevation)
        {
            var outCart = new Vector3();
            float a = radius * Mathf.Cos(elevation);
            outCart.x = a * Mathf.Cos(polar);
            outCart.y = radius * Mathf.Sin(elevation);
            outCart.z = a * Mathf.Sin(polar);

            return outCart;
        }

        static void CartesianToSpherical(Vector3 cartCoords, out float outRadius, out float outPolar, out float outElevation)
        {
            if (cartCoords.x == 0)
                cartCoords.x = Mathf.Epsilon;
            outRadius = Mathf.Sqrt((cartCoords.x * cartCoords.x)
                            + (cartCoords.y * cartCoords.y)
                            + (cartCoords.z * cartCoords.z));
            outPolar = Mathf.Atan(cartCoords.z / cartCoords.x);
            if (cartCoords.x < 0)
                outPolar += Mathf.PI;
            outElevation = Mathf.Asin(cartCoords.y / outRadius);
        }
    }

    class OrderedSphericalTransform : SphericalTransform
    {
        public readonly int Order;

        public OrderedSphericalTransform(SphericalTransform sphericalTransform, int order) : base(sphericalTransform)
        {
            Order = order;
        }
    }

    class SphericalTransform
    {
        public readonly SphericalCoordinate Spherical;
        public float Radius { get { return Spherical.Radius; } }
        public float Polar { get { return Spherical.Polar; } }
        public float Elevation { get { return Spherical.Elevation; } }

        public readonly Transform Transform;

        protected SphericalTransform(SphericalTransform other)
        {
            Spherical = other.Spherical;
            Transform = other.Transform;
        }

        public SphericalTransform(Vector3 cartesian, Transform transform)
        {
            Spherical = new SphericalCoordinate(cartesian);
            Transform = transform;
        }
    }
}

おわりに

コミティアで出したVR音ゲーそのうち出し・・・たい。

この記事は @chiepomme Advent Calender 2015 で書きました。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした