[译]如何在Unity编辑器中添加你自己的工具

在这篇教程中你会学习如何扩展你的Unity3D编辑器,以便在你的项目中更好的使用它。你将会学习如何绘制你自己的gizmo,用代码来实现创建和删除物体,创建编辑器窗口,使用组件,并且允许用户撤销他们所作出的任何动作,这些全部都是用编辑器脚本来实现的。

这篇教程假设你已经熟悉Unity的基本工作流程。如果你知道如何在编辑器中创建物体、预设、场景并且知道如何移动它们,知道如何添加组件,那么你可以开始本教程的学习了。

最终结果预览

让我们看一下我们做出的最终结果是什么样子:
[译]如何在Unity编辑器中添加你自己的工具_第1张图片
如你所见,我们会创建一个编辑器窗口,里面有一个颜色拾取器,我们可以用所选取的颜色来绘制网格。我们也能够创建和删除物体,把他们对齐到网格,并且能够撤销这些动作。


Step 1: Gizmos

首先我们来学习如何使用gizmo。这里有一些内置gizmo的例子:
[译]如何在Unity编辑器中添加你自己的工具_第2张图片
你可能会经常在Unity中看到这个gizmo,因为它会为每一个拥有transform组件的物体绘制,因此基本上每一个被选中的物体都会有这个gizmo。
[译]如何在Unity编辑器中添加你自己的工具_第3张图片
这是另一个gizmo,它能够让我们知道绑定在我们游戏对象上的BoxCollider的大小。

Step 2: 创建一个Grid脚本

创建一个C#脚本Grid.cs,我们用它来为一个物体绘制我们自己的gizmo;我们会在编辑器中绘制一个简单的网格(Grid)来作为一个例子。

using UnityEngine;
using System.Collections;

public class Grid : MonoBehaviour
{
    void Start()
    {
    }

    void Update()
    {
    }
}


对于一个网格,我们需要添加2个变量:width和height

public class Grid : MonoBehaviour
{
    public float width = 32.0f;
    public float height = 32.0f;

    void Start()
    {
    }

    void Update()
    {
    }
}


为了在编辑器中绘图,我们需要使用OnDrawGizmo回调函数,因此让我们创建它。

public class Grid : MonoBehaviour
{
    public float width = 32.0f;
    public float height = 32.0f;

    void Start()
    {
    }

    void Update()
    {
    }

    void OnDrawGizmos()
    {
    }
}

 

Step 3: 绘制网格(Grid)

要绘制一个网格,我们需要一系列水平线和垂直线,并且还要知道编辑器摄像机(也叫场景摄像机)的当前位置以便我们知道我们应该把我们的网格绘制在什么地方。首先,把摄像机的位置保存在一个单独的变量中

void OnDrawGizmos()
{
    Vector3 pos = Camera.current.transform.position;
}


如你所见,我们可以使用Camera.current引用来获取编辑器摄像机。
现在,我们需要2个for循环来绘制水平线和垂直线。

void OnDrawGizmos()
{
    Vector3 pos = Camera.current.transform.position;

    for (float y = pos.y - 800.0f; y < pos.y + 800.0f; y += height)
    {
        Gizmos.DrawLine(new Vector3(-1000000.0f, Mathf.Floor(y/height) * height, 0.0f),
                        new Vector3(1000000.0f, Mathf.Floor(y/height) * height, 0.0f));
    }

    for (float x = pos.x - 1200.0f; x < pos.x + 1200.0f; x += width)
    {
        Gizmos.DrawLine(new Vector3(Mathf.Floor(x/width) * width, -1000000.0f, 0.0f),
                        new Vector3(Mathf.Floor(x/width) * width, 1000000.0f, 0.0f));
    }
}

我们使用Gizmos.DrawLine()来绘制线。注意Gizmos类有很多绘制API,因此绘制一个cube或者sphere甚至是它们的线框这样的几何图元(primitive)都不成问题。如果需要,你也可以绘制一幅图片。

网格线应该是无限长的,但是float.positiveInfinityfloat.negativeInfinity似乎在绘制直线时不能正常工作,因此我们只是简单的放置一个任意的大数来替代它。同时,线的数量严格取决于我们在for循环中使用的常数;技术上来说,我们不应该在代码中放置这样的常数,但这仅仅是一个测试代码,所以无视它就好。

要观察网格,你需要创建一个空物体,然后把我们的脚本Grid.cs绑定到它上面:
[译]如何在Unity编辑器中添加你自己的工具_第4张图片

Step 4: 创建一个自定义的Inspector

接下来的工作就是自定义Inspector。我们需要创建一个编辑器脚本来做这件事。创建一个新的C#文件,命名为GridEditor。这个脚本需要放置在Editor文件夹中;如果你还没有一个Editor文件夹,那么现在就创建它吧。

using UnityEngine;
using UnityEditor;
using System.Collections;

[CustomEditor (typeof(Grid))]
public class GridEditor : Editor
{
}


这一次我们也需要using UnityEditor以便我们能够使用编辑器的类和函数。为了覆盖我们的Grid物体的默认inspector,我们需要在我们的类声明之前添加一个属性,[CustomEditor(typeof(Grid))]会告诉Unity我们想要自定义Grid的inspector。为了能够使用编辑器的回调函数,我们需要继承Editor类而不是MonoBehaviour类。
要改变当前inspector,我们需要覆盖掉旧的那个。

public class GridEditor : Editor
{
    public override void OnInspectorGUI()
    {
    }
}

 

现在如果你检查编辑器中的Grid物体的inspector,你会发现即便它有一些public成员,inspector里面也是空空如也。这是因为通过覆盖OnInspectorGUI()我们丢弃了默认的inspector,取而代之的是一个自定义的inspector。
overridden_inspector

Step 5: 使用GUILayout来填充我们的自定义Inspector

在我们创建任何字段(field)之前,我们需要取得自定义inspector所对应的那个对象的引用。我们实际上已经有了该对象的引用——它的名字叫target——但是为了方便我们会创建一个该对象上Grid组件的引用。首先,让我们先声明它。

public class GridEditor : Editor
{
    Grid grid;

 

我们应该在OnEnable()函数中为它赋值,该函数会在inspector可用时调用。

public class GridEditor : Editor
{
    Grid grid;

    public void OnEnable()
    {
        grid = (Grid)target;
    }


现在,让我们为inspector创建一些字段。我们会使用GUILayout和EditorGUILayout类来做这件事。

public override void OnInspectorGUI()
{
    GUILayout.BeginHorizontal();
    GUILayout.Label("Grid Width");
    grid.width = EditorGUILayout.FloatField(grid.width, GUILayout.Width(50));
    GUILayout.EndHorizontal();
}

 

第一行的GUILayout.BeginHorizontal()表示,我们想要把接下来的inspector元素彼此从左到右放置。最后一行GUILayout.EndHorizontal(),如你所想的那样,表示我们不想再那样做了。实际添加的项目位于这两行之间。第一个添加的是一个简单的标签(在我们的案例中,它会显示为Grid Width文本),紧挨着它,我们创建了一个EditorGUILayout.FloatField,如你所想的那样,它是一个float字段。注意我们把FloatField的值赋给了grid.width,而FloatField本身又显示grid.width的值。我们设置它的宽度为50像素。

让我们看看字段是否正确地添加到了inspector:
overridden_inspector (1)

Step 6: 填充inspector并且重绘场景

现在让我们添加另一个项目到inspector中;这一次它是grid.height

public override void OnInspectorGUI()
{
    GUILayout.BeginHorizontal();
    GUILayout.Label("Grid Width");
    grid.width = EditorGUILayout.FloatField(grid.width, GUILayout.Width(50));
    GUILayout.EndHorizontal();

    GUILayout.BeginHorizontal();
    GUILayout.Label("Grid Height");
    grid.height = EditorGUILayout.FloatField(grid.height, GUILayout.Width(50));
    GUILayout.EndHorizontal();
}


这就是我们Grid对象的全部字段了,如果你想知道在inspector中还可以使用哪些字段和项目,你可以查看Unity手册中的EditorGUILayout和GUILayout类。

 

注意我们在新inspector中所作出的改变,只有当我们选中了场景视图窗口之后才会可见。为了让它在作出改变之后立即可见,我们可以调用SceneView.RepaintAll()

public override void OnInspectorGUI()
{
    GUILayout.BeginHorizontal();
    GUILayout.Label("Grid Width");
    grid.width = EditorGUILayout.FloatField(grid.width, GUILayout.Width(50));
    GUILayout.EndHorizontal();

    GUILayout.BeginHorizontal();
    GUILayout.Label("Grid Height");
    grid.height = EditorGUILayout.FloatField(grid.height, GUILayout.Width(50));
    GUILayout.EndHorizontal();

    SceneView.RepaintAll();
}


现在,我们不必再点击inspector的外面就可以看到变化后的结果了。
[译]如何在Unity编辑器中添加你自己的工具_第5张图片

 

Step 7: 处理编辑器输入

现在,让我们尝试处理编辑器的输入,就像我们在游戏中所做的那样。任何按键或鼠标状态对我们都可用。为了取得这样的效果,我们需要在我们的SceneView中添加一个onSceneGUIDelegate回调函数。让我们调用我们自己的更新函数GridUpdate().

public void OnEnable()
{
    grid = (Grid)target;
    SceneView.onSceneGUIDelegate = GridUpdate;
}
void GridUpdate(SceneView sceneview)
{
}


现在,我们只需获取输入事件(input Event)

void GridUpdate(SceneView sceneview)
{
    Event e = Event.current;
}

 

Step 8: 创建一个预设(Prefab)

为了进一步把弄编辑器脚本,我们需要一个能够使用的游戏对象。让我们创建一个简单的cube并把它做成prefab。
[译]如何在Unity编辑器中添加你自己的工具_第6张图片
你可以调整网格的大小来匹配cube或者相反(调整cube的大小来匹配网格),最终让cube和网格对齐。
[译]如何在Unity编辑器中添加你自己的工具_第7张图片

如你所见,Hierarchy面板中的cube文本变成蓝色的,这意味着它链接到了一个prefab。你可以在Project面板中看到那个预设(prefab)。

Step 9: 从编辑器脚本中创建一个物体

现在,我们将从编辑器脚本中创建一个物体。让我们回到我们的GridEditor.cs中来扩展我们的GridUpdate()函数。
当键盘上的A键被按下时,我们就创建一个物体。

void GridUpdate(SceneView sceneview)
{
    Event e = Event.current;

    if (e.isKey && e.character == 'a')
    {
        GameObject obj;
    }
}


如你所见,我们简单地检查一下触发的事件是否是一个按键的状态改变事件并且按下的那个按键的字符是否是'a'。然后我们为我们的新物体创建了一个引用。现在,让我们实例化它。

void GridUpdate(SceneView sceneview)
{
    Event e = Event.current;

    if (e.isKey && e.character == 'a')
    {
        GameObject obj;
        if (Selection.activeObject)
        {
            obj = (GameObject)Instantiate(Selection.activeObject);
            obj.transform.position = new Vector3(0.0f, 0.0f, 0.0f);
        }
    }
}


Selection.activeObject是编辑器中当前所选中物体的一个引用。如果选中任何一个物体,那么我们就简单地克隆它,并把克隆体的位置设置为(0,0,0).

让我们测试一下它是否正常工作。你必须知道的一件事:无论什么时候我们的资源(assets)被重新导入/刷新,我们的GridUpdate()都会停止工作。(这通常都是当我们修改完代码后回到编辑器中时)这时候,你可以选中编辑器脚本所指向的对象(例如在Hierarchy视图中选中该对象)—在我们的例子中它是Grid物体,然后重新激活一下就可以了。另一件你需要知道的事情是输入事件只有在场景视图被选中的情况下才会被捕捉到。(也就是场景视图获得焦点)
[译]如何在Unity编辑器中添加你自己的工具_第8张图片

Step 10: 从编辑器脚本中实例化一个预设(prefab)

尽管我们想方设法想要克隆对象,但克隆的对象所链接的那个预设(prefab)并不存在。
 [译]如何在Unity编辑器中添加你自己的工具_第9张图片
正如你所看到的,Cube(Clone)物体的名字被显示成纯黑色字体,也就意味着它并没有链接到原始立方体所链接的那个预设上。如果我们在编辑器中手动复制(duplicate)那个原始的立方体,那么克隆的立方体将会链接到Cube预设上。为了让它按这样的方式为我们工作,我们需要使用InstantiatePrefab()函数,该函数来自EditorUtility类。

在我们使用该函数之前,我们需要取得被选中物体的预设(prefab)。我们可以使用GetPrefabParent()函数来做这件事,该函数同样来自于EditorUtility类。

 

void GridUpdate(SceneView sceneview)
{
    Event e = Event.current;

    if (e.isKey && e.character == 'a')
    {
        GameObject obj;
        Object prefab = EditorUtility.GetPrefabParent(Selection.activeObject);

        if (prefab)
        {
        }
    }
}

Selection.activeObject没有预设(prefab)时,我们可以停止检查,因为如果它的预设(prefab)不存在,那么prefab变量会等于null,因此我们可以仅仅使用一个prefab引用来来避免检查。

现在,让我们实例化我们的预设并设置它的位置。

void GridUpdate(SceneView sceneview)
{
    Event e = Event.current;

    if (e.isKey && e.character == 'a')
    {
        GameObject obj;
        Object prefab = EditorUtility.GetPrefabParent(Selection.activeObject);

        if (prefab)
        {
            obj = (GameObject)EditorUtility.InstantiatePrefab(prefab);
            obj.transform.position = new Vector3(0.0f, 0.0f, 0.0f);
        }
    }
}


这就是它的真实面目—现在让我们检查一下克隆的立方体是否链接到了预设(prefab)。
[译]如何在Unity编辑器中添加你自己的工具_第10张图片

 

Step 11: 把屏幕上的鼠标坐标转换为世界坐标

Event类不告诉我们鼠标在世界空间中的位置,它只告诉我们鼠标在屏幕空间中的坐标。下面展示了我们如何在这两者之间进行转换,以便我们可以得到一个近似的世界空间中的鼠标坐标。

void GridUpdate(SceneView sceneview)
{
    Event e = Event.current;

    Ray r = Camera.current.ScreenPointToRay(new Vector3(e.mousePosition.x, -e.mousePosition.y + Camera.current.pixelHeight));
    Vector3 mousePos = r.origin;
}

 

首先,我们使用编辑器摄像机的ScreenPointToRay方法来得到一个从屏幕坐标发出的射线,但不幸的是,在那之前我们需要把事件的屏幕空间转换成ScreenPointToRay()可接受的一个屏幕空间。
e.mousePosition里面保存着鼠标在一个特殊屏幕空间中的位置,这个特殊的屏幕空间的左上角的坐标是(0,0)点,右下角的坐标等于(Camera.current.pixelWidth, -Camera.current.pixelHeight)。我们需要把它转换成ScreenPointToRay()可接受的屏幕空间,也就是左下角的坐标是(0,0),右上角的坐标是(Camera.current.pixelWidth, Camera.current.pixelHeight),这种转换是很简单的。

接下来我们应该做的就是把射线的原点存放在我们的mousePos向量中,以便它可以很容易的访问。
现在,我们可以把克隆物体的位置设置为鼠标所在的位置:

if (prefab)
{
    obj = (GameObject)EditorUtility.InstantiatePrefab(prefab);
    obj.transform.position = new Vector3(mousePos.x, mousePos.y, 0.0f);
}

 

注意 when the camera is set really flat then the approximation of mouse position on one of the axes is really really bad,那就是为什么我要手动设置克隆体的z坐标。现在,cube应该会在鼠标所在的位置被创建。
[译]如何在Unity编辑器中添加你自己的工具_第11张图片

Step 12: 把立方体对齐到网格

因为我们已经设置好了我们的网格,如果不用它的话那会是一种耻辱;就让我们使用我们的鼠标位置来把创建的立方体对齐到网格上。

if (prefab)
{
    obj = (GameObject)EditorUtility.InstantiatePrefab(prefab);
    Vector3 aligned = new Vector3(Mathf.Floor(mousePos.x/grid.width)*grid.width + grid.width/2.0f,
                                  Mathf.Floor(mousePos.y/grid.height)*grid.height + grid.height/2.0f, 0.0f);
    obj.transform.position = aligned;
}


看一下结果:
[译]如何在Unity编辑器中添加你自己的工具_第12张图片

 

Step 13: 从编辑器脚本中销毁一个对象物体

在这一步中,我们将在编辑器中编程来删除物体。我们可以使用DestroyImmediate()来实现。在这个案例中让我们充分利用Selection类,当'd'键被按下时删除所有被选中的物体。

if (e.isKey && e.character == 'a')
{
    GameObject obj;
    Object prefab = EditorUtility.GetPrefabParent(Selection.activeObject);

    if (prefab)
    {
        obj = (GameObject)EditorUtility.InstantiatePrefab(prefab);
        Vector3 aligned = new Vector3(Mathf.Floor(mousePos.x/grid.width)*grid.width + grid.width/2.0f,
                                      Mathf.Floor(mousePos.y/grid.height)*grid.height + grid.height/2.0f, 0.0f);
        obj.transform.position = aligned;
    }
}
else if (e.isKey && e.character == 'd')
{
    foreach (GameObject obj in Selection.gameObjects)
        DestroyImmediate(obj);
}


当'd'键被按下时,我们遍历所有被选中的物体并且删除它们中的每一个物体。当然,我们也可以在编辑器中按Delete键来删除这些物体,但是这样的话就不是由我们的脚本删除的,而是系统删除的。在编辑器中测试一下。

 

Step 14: 撤销物体的实例化

在这一步中,我们会使用Undo类,这个类会让我们撤销我们的编辑器脚本所作出的每一个动作。让我们从撤销对象创建开始。

为了能够销毁我们在编辑器中所创建的对象,我们需要调用Undo.RegisterCreatedObjectUndo()函数。它接收2个参数:第一个参数是一个已经创建的对象,第二个参数是撤销动作的名称。这个撤销动作的名称总是显示在Edit->Undo name下面。

if (prefab)
{
    obj = (GameObject)EditorUtility.InstantiatePrefab(prefab);
    Vector3 aligned = new Vector3(Mathf.Floor(mousePos.x/grid.width)*grid.width + grid.width/2.0f,
                                  Mathf.Floor(mousePos.y/grid.height)*grid.height + grid.height/2.0f, 0.0f);
    obj.transform.position = aligned;
    Undo.RegisterCreatedObjectUndo(obj, "Create " + obj.name);
}

 

如果你使用a键创建了一些立方体,然后尝试撤销它们,那么现在你会注意到所有被创建的立方体都会被删除。这是由于所有这些被创建的立方体都进入到同一个撤销事件中。

Step 15: 撤销单个物体的实例化

如果我们想把每一个被创建的物体放置在不同的撤销事件中,使我们能够逐个地撤销创建过程,我们需要使用Undo.IncrementCurrentEventIndex().

 

if (prefab)
{
    Undo.IncrementCurrentEventIndex();
    obj = (GameObject)EditorUtility.InstantiatePrefab(prefab);
    Vector3 aligned = new Vector3(Mathf.Floor(mousePos.x/grid.width)*grid.width + grid.width/2.0f,
                                  Mathf.Floor(mousePos.y/grid.height)*grid.height + grid.height/2.0f, 0.0f);
    obj.transform.position = aligned;
    Undo.RegisterCreatedObjectUndo(obj, "Create " + obj.name);
}


现在如果你测试这个脚本,你会看到通过撤销对他们的创建,立方体会被逐个地删除。

 

Step 16: 撤销物体的删除

要撤销对物体的删除动作,我们需要使用Undo.RegisterSceneUndo()函数。它是一个非常慢的函数,因为它会保存场景的所有状态,以便我们随后可以通过执行撤销动作来恢复到那个状态。不幸的是,它似乎是当前我们能够让被删除的对象回到场景中的唯一办法了。

else if (e.isKey && e.character == 'd')
{
    Undo.IncrementCurrentEventIndex();
    Undo.RegisterSceneUndo("Delete Selected Objects");
    foreach (GameObject obj in Selection.gameObjects)
        DestroyImmediate(obj);
}


Undo.RegisterSceneUndo()函数只接收一个参数,那就是撤销动作的名称。通过'd'键删除一些立方体之后,你可以撤销那些删除操作。

 

Step 17: 创建一个编辑器窗口脚本

创建一个新的脚本,并且让它继承EditorWindow而不是Editor。我们把它命名为GridWindow.cs

 

using UnityEngine;
using UnityEditor;
using System.Collections;

public class GridWindow : EditorWindow
{
    public void Init()
    {
    }
}

 

让我们创建一个对我们的Grid对象的引用,以便我们可以在窗口中访问它。

public class GridWindow : EditorWindow
{
    Grid grid;

    public void Init()
    {
        grid = (Grid)FindObjectOfType(typeof(Grid));
    }
}


现在,我们需要创建这个窗口了,我们可以在我们的GridEditor脚本中来创建。

 

Step 18: 创建GridWindow

在我们的OnInspectorGUI()函数中添加一个按钮,用来创建GridWindow.

public override void OnInspectorGUI()
{
    GUILayout.BeginHorizontal();
    GUILayout.Label("Grid Width");
    grid.width = EditorGUILayout.FloatField(grid.width, GUILayout.Width(50));
    GUILayout.EndHorizontal();

    GUILayout.BeginHorizontal();
    GUILayout.Label("Grid Height");
    grid.height = EditorGUILayout.FloatField(grid.height, GUILayout.Width(50));
    GUILayout.EndHorizontal();

    if (GUILayout.Button("Open Grid Window", GUILayout.Width(255)))
    {
        GridWindow window = (GridWindow) EditorWindow.GetWindow(typeof(GridWindow));
        window.Init();
    }

    SceneView.RepaintAll();
}

 

我们使用GUILayout来创建一个按钮,同时设置按钮的名称和宽度。当按钮被按下时,GUILayout.Button()返回true,在这种情况下,我们打开我们的GridWindow.

你可以返回到编辑器中,按下我们的Grid对象的inspector面板中的按钮。
grid_window_button

一旦你这样做了,GridWindow就会弹出来。
[译]如何在Unity编辑器中添加你自己的工具_第13张图片

Step 19: 在GridWindow中创建一个颜色字段(Color Field)

在我们编辑我们的窗口之前,让我们添加一个颜色字段到我们的Grid类中,以便我们可以在后面编辑它。

public class Grid : MonoBehaviour
{
    public float width = 32.0f;
    public float height = 32.0f;

    public Color color = Color.white;

 

现在,在OnDrawGizmos()函数中为Gizmos.color赋值。

void OnDrawGizmos()
{
    Vector3 pos = Camera.current.transform.position;
    Gizmos.color = color;

 

现在,让我们回到GridWindow脚本中来,在窗口中创建一个颜色字段以便我们可以在窗口中拾取颜色。我们可以在OnGUI()回调函数中做这件事。

public class GridWindow : EditorWindow
{
    Grid grid;

    public void Init()
    {
        grid = (Grid)FindObjectOfType(typeof(Grid));
    }

    void OnGUI()
    {
        grid.color = EditorGUILayout.ColorField(grid.color, GUILayout.Width(200));
    }
}

 

好了,现在你可以在编辑器中检查是否一切工作正常。
[译]如何在Unity编辑器中添加你自己的工具_第14张图片

Step 20: 添加一个委托(Delegate)

现在,我们是使用符号=来设置一个委托,这个委托用来从场景视图中获取输入事件。这种做法并不是一个好的方法,因为它会覆盖掉所有其他的回调函数。我们应该使用+=符号来代替=。让我们回到我们的GridWindow.cs脚本中,改变这种做法。

public void OnEnable()
{
    grid = (Grid)target;
    SceneView.onSceneGUIDelegate += GridUpdate;
}

 

我们也需要创建一个OnDisable()回调函数用来删除我们的GridUpdate(),如果我们不这样做的话,它就会进行叠加,然后在一次事件中被调用多次。

public void OnEnable()
{
    grid = (Grid)target;
    SceneView.onSceneGUIDelegate += GridUpdate;
}
public void OnDisable()
{
    SceneView.onSceneGUIDelegate -= GridUpdate;
}

 

结论:

这就是编辑器脚本的介绍。如果你想扩充你的知识,在Unity脚本手册中有很多可阅读的话题——根据你的需要你可能想要查看Resources, AssetDatabase或者FileUtil类来了解更多内容。

不幸的是,一些类还没有被写进文档。由于这个原因,很容易导致不能工作。例如,SceneView类和它的函数或者是Undo类中的Undo.IncrementCurrentEventIndex()函数。如果文档中没有提供你要找的答案,你可能需要在UnityAnswers或者Unity Forum中搜索一下了。

感谢您花时间阅读这篇文章!

写在最后的话

点击这里,查看英文原版。
点击这里,查看中文翻译版。
该文章来源于Envato网站上的Tuts+,版权属原作者所有!如果转载,请保留到原文的链接!谢谢!

转载于:https://www.cnblogs.com/champ/p/4148875.html

你可能感兴趣的:([译]如何在Unity编辑器中添加你自己的工具)