11.创建VAT编辑器功能入口

11.具体实现-烘焙部分-编辑器功能入口


11.1 知识点

为何要创建编辑器入口

  • 之前几篇补充知识中,我们是分散地学习了如何获取动画信息、如何烘焙顶点数据、如何归一化、如何写入纹理。
  • 现在需要把这些零散的步骤整合成一个完整的工具。
  • 通过编辑器菜单入口,可以方便地选中目标对象后一键生成 VAT。

如何创建编辑器入口

主要目的

  • 创建一个编辑器工具,选中目标对象后通过菜单一键生成 VAT 纹理。

主要思路

  • 使用 [MenuItem] 特性创建菜单入口。
  • 通过 Selection.activeObject 获取当前选中的对象。
  • 整合之前学习的所有步骤:获取动画信息 → 烘焙顶点 → 归一化 → 写入纹理 → 设置导入参数。
  • 添加必要的容错校验。

主要步骤

  1. 定义存储路径常量,方便统一管理。
  2. 创建菜单入口,获取选中的目标对象。
  3. 校验目标对象是否具备必要的组件(Animator、SkinnedMeshRenderer)。
  4. 获取动画切片信息。
  5. 遍历所有动画帧,统计顶点坐标的最小值和最大值。
  6. 再次遍历,将归一化后的顶点坐标写入纹理。
  7. 保存 PNG 文件并设置导入参数。

注意

  • 脚本需要放在 Editor 目录下,因为使用了 UnityEditor 命名空间。
  • 存储路径需要提前创建好,否则 WriteAllBytes 会报错。
  • 当前示例仅存储顶点位置,若需存储法线或切线,需创建额外的纹理。

具体操作

定义存储路径常量
  • 使用两个常量分别存储绝对路径和相对路径。
  • 绝对路径用于 File.WriteAllBytes 写入文件。
  • 相对路径用于 AssetImporter.GetAtPath 获取导入器。
// 默认存储路径 方便之后修改路径进行修改 因此我们把它写在前面
private static string SAVA_VAT_PATH = Application.dataPath + "/Art/VAT/";
private static string SETTING_PATH = "Assets/Art/VAT/";
创建菜单入口
  • 使用 [MenuItem] 特性在 Unity 编辑器菜单栏创建入口。
  • 通过 Selection.activeObject 获取当前选中的对象。
[MenuItem("VAT Tool/CreateVAT")]
public static void CreateVertexAnimationTexture()
{
    // 获取当前在编辑器中选择的对象 来进行生成
    GameObject selObj = Selection.activeObject as GameObject;
    CreateVATFuc(selObj);
}
获取选中对象并校验
  • 这部分代码整合自第 4 篇补充知识,添加了容错校验。
  • 校验目标对象是否为空。
  • 校验是否挂载 Animator 组件。
  • 校验是否挂载 SkinnedMeshRenderer 组件。
// 1.得到目标GameObject targetGameObject
if (targetGameObject == null)
{
    Debug.LogError("你应该选择一个想要生成VAT的目标对象");
    return;
}

// 2.得到GameObject上的Animator组件
Animator animator = targetGameObject.GetComponent<Animator>();
if (animator == null)
{
    Debug.LogError("它需要挂载Animator组件去关联对应动画");
    return;
}

// 3.通过Animator组件得到AnimatorController
AnimatorController animatorController = animator.runtimeAnimatorController as AnimatorController;
if (animatorController == null)
{
    return;
}

// 4.得到AnimatorController中对应层的状态机AnimatorStateMachine
AnimatorStateMachine animatorStateMachine = animatorController.layers[0].stateMachine;

// 5.得到状态机中的所有状态ChildAnimatorState[]数组
ChildAnimatorState[] childAnimatorStateArray = animatorStateMachine.states;

// 之所以绕一圈去获取动画切片 是为了之后做铺垫
// 我们可以结合Animator,蒙皮网格渲染器,利用他们一起去得到某一个动画某一帧对应的顶点数据

// 拿到SkinnedMeshRenderer和创建Mesh
SkinnedMeshRenderer skinnedMeshRenderer = targetGameObject.GetComponentInChildren<SkinnedMeshRenderer>();
if (skinnedMeshRenderer == null)
{
    Debug.LogError("该对象或其子对象上没有找到蒙皮网格渲染器信息");
    return;
}

Mesh mesh = new Mesh();
遍历计算顶点坐标的 min 和 max
  • 这部分代码整合自第 5、6 篇补充知识。
  • 初始化 minint.MaxValuemaxint.MinValue
  • 遍历所有动画切片、所有帧、所有顶点,统计全局最小值和最大值。
// 用于存储顶点最大最小值的变量
int min = int.MaxValue;
int max = int.MinValue;

for (int i = 0; i < childAnimatorStateArray.Length; i++)
{
    AnimatorState state = childAnimatorStateArray[i].state;
    AnimationClip animClip = state.motion as AnimationClip;
    int frameCount = Mathf.CeilToInt(animClip.length * animClip.frameRate);

    // 需要把每一帧对应的时间点传递进去 => 帧数转时间 帧数/帧率
    for (int j = 0; j < frameCount; j++)
    {
        // 一帧一帧的得到对应的顶点数据 存储到VAT中
        // 让对象定格到了某一帧
        animClip.SampleAnimation(targetGameObject, j / animClip.frameRate);
        // 得到当前帧的顶点信息
        // 每次烘焙时把上一次的数据清理了
        mesh.Clear(false);
        // 得到了此时此刻的网格相关的数据
        skinnedMeshRenderer.BakeMesh(mesh);
        // 得到了此时此刻网格对应的所有顶点位置
        Vector3[] vertices = mesh.vertices;
        Vector3[] normals = mesh.normals;
        Vector4[] tangents = mesh.tangents;

        // 我们需要遍历所有动画切片的所有帧的网格顶点位置
        // 从而找到最大最小值 才能进行归一化计算
        // 帧数 * 顶点数
        // (57+50+20)* 9500
        for (int k = 0; k < vertices.Length; k++)
        {
            Vector3 nowVertice = vertices[k];
            min = Mathf.FloorToInt(Mathf.Min(min, nowVertice.x, nowVertice.y, nowVertice.z));
            max = Mathf.CeilToInt(Mathf.Max(max, nowVertice.x, nowVertice.y, nowVertice.z));
        }
    }
}

Debug.Log("顶点坐标最小值" + min);
Debug.Log("顶点坐标最大值" + max);
写入纹理并保存
  • 这部分代码整合自第 7 篇补充知识。
  • 创建 Texture2D 对象。
  • 再次遍历,将归一化后的顶点坐标写入纹理像素。
  • 编码为 PNG 并写入磁盘。
  • 设置导入参数:关闭压缩、点过滤、关闭 mipmap、关闭 sRGB。
// 创建一个2048x2048尺寸的纹理对象
// 参数说明:
// - 2048, 2048:纹理的宽度和高度(像素)
// - TextureFormat.RGBA32:纹理格式为RGBA32(每个像素占4字节,红/绿/蓝/透明度各8位)
// - false:不生成多级渐远纹理(mipmap),减少内存占用且提升创建速度
// - true:线性空间;数据贴图用 true,避免当 sRGB 颜色纹理做伽马校正
Texture2D tex = new Texture2D(2048, 2048, TextureFormat.RGBA32, false, true);
// 当前像素索引
int nowPiexlIndex = 0;

for (int i = 0; i < childAnimatorStateArray.Length; i++)
{
    AnimatorState state = childAnimatorStateArray[i].state;
    AnimationClip animClip = state.motion as AnimationClip;
    int frameCount = Mathf.CeilToInt(animClip.length * animClip.frameRate);

    // 需要把每一帧对应的时间点传递进去 => 帧数转时间 帧数/帧率
    for (int j = 0; j < frameCount; j++)
    {
        // 一帧一帧的得到对应的顶点数据 存储到VAT中
        // 让对象定格到了某一帧
        animClip.SampleAnimation(targetGameObject, j / animClip.frameRate);
        // 得到当前帧的顶点信息
        // 每次烘焙时把上一次的数据清理了
        mesh.Clear(false);
        // 得到了此时此刻的网格相关的数据
        skinnedMeshRenderer.BakeMesh(mesh);
        // 得到了此时此刻网格对应的所有顶点位置
        Vector3[] vertices = mesh.vertices;
        Vector3[] normals = mesh.normals;
        Vector4[] tangents = mesh.tangents;

        // 我们需要遍历所有动画切片的所有帧的网格顶点位置
        // 从而找到最大最小值 才能进行归一化计算
        // 帧数 * 顶点数
        // (57+50+20)* 9500
        for (int k = 0; k < vertices.Length; k++)
        {
            Vector3 nowVertice = vertices[k];
            Vector3 normalVertice = new Vector3(
                Mathf.InverseLerp(min, max, nowVertice.x),
                Mathf.InverseLerp(min, max, nowVertice.y),
                Mathf.InverseLerp(min, max, nowVertice.z)
            );

            // 通过索引得到 x y 坐标
            int x = nowPiexlIndex % 2048;
            int y = nowPiexlIndex / 2048;
            tex.SetPixel(x, y, new Color(normalVertice.x, normalVertice.y, normalVertice.z, 1));
            ++nowPiexlIndex;
        }
    }
}

// 循环结束后 像素存储完毕了
tex.Apply(false, false);
File.WriteAllBytes(SAVA_VAT_PATH + targetGameObject.name + ".png", tex.EncodeToPNG());
AssetDatabase.Refresh();

// 纹理生成完毕了 对它进行一劳永逸的基础设置
TextureImporter textureImporter = AssetImporter.GetAtPath(SETTING_PATH + targetGameObject.name + ".png") as TextureImporter;
textureImporter.textureCompression = TextureImporterCompression.Uncompressed;
textureImporter.filterMode = FilterMode.Point;
textureImporter.mipmapEnabled = false;
textureImporter.sRGBTexture = false;
textureImporter.SaveAndReimport();

11.2 知识点代码

CreateVAT.cs

using System.IO;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;

public class CreateVAT
{
    // 默认存储路径 方便之后修改路径 进行修改 因此我们把它写在前面
    private static string SAVA_VAT_PATH = Application.dataPath + "/Art/VAT/";
    private static string SETTING_PATH = "Assets/Art/VAT/";

    [MenuItem("VAT Tool/CreateVAT")]
    public static void CreateVertexAnimationTexture()
    {
        // 获取当前在编辑器中选择的对象 来进行生成
        GameObject selObj = Selection.activeObject as GameObject;
        CreateVATFuc(selObj);
    }

    private static void CreateVATFuc(GameObject targetGameObject)
    {
        // 1.得到目标GameObject targetGameObject
        if (targetGameObject == null)
        {
            Debug.LogError("你应该选择一个想要生成VAT的目标对象");
            return;
        }

        // 2.得到GameObject上的Animator组件
        Animator animator = targetGameObject.GetComponent<Animator>();
        if (animator == null)
        {
            Debug.LogError("它需要挂载Animator组件去关联对应动画");
            return;
        }

        // 3.通过Animator组件得到AnimatorController
        AnimatorController animatorController = animator.runtimeAnimatorController as AnimatorController;
        if (animatorController == null)
        {
            return;
        }

        // 4.得到AnimatorController中对应层的状态机AnimatorStateMachine
        AnimatorStateMachine animatorStateMachine = animatorController.layers[0].stateMachine;

        // 5.得到状态机中的所有状态ChildAnimatorState[]数组
        ChildAnimatorState[] childAnimatorStateArray = animatorStateMachine.states;

        // 6.遍历该数组
        //   其中每个状态的motion就是对应的动画切片AnimationClip
        //   这样我们就能得到帧率、时长等关键信息
        for (int i = 0; i < childAnimatorStateArray.Length; i++)
        {
            AnimatorState animatorState = childAnimatorStateArray[i].state;
            AnimationClip animationClip = animatorState.motion as AnimationClip;

            // animationClip.frameRate 帧率
            // animationClip.length 时长
            int frameCount = Mathf.CeilToInt(animationClip.frameRate * animationClip.length); // 得到这个clip多少帧
        }

        // 之所以绕一圈去获取动画切片 是为了之后做铺垫
        // 我们可以结合Animator,蒙皮网格渲染器,利用他们一起去得到某一个动画某一帧对应的顶点数据

        SkinnedMeshRenderer skinnedMeshRenderer = targetGameObject.GetComponentInChildren<SkinnedMeshRenderer>();
        if (skinnedMeshRenderer == null)
        {
            Debug.LogError("该对象或其子对象上没有找到蒙皮网格渲染器信息");
            return;
        }

        Mesh mesh = new Mesh();

        // 用于存储顶点最大最小值的变量
        int min = int.MaxValue;
        int max = int.MinValue;

        for (int i = 0; i < childAnimatorStateArray.Length; i++)
        {
            AnimatorState state = childAnimatorStateArray[i].state;
            AnimationClip animClip = state.motion as AnimationClip;
            int frameCount = Mathf.CeilToInt(animClip.length * animClip.frameRate);

            // 需要把每一帧对应的时间点传递进去 => 帧数转时间 帧数/帧率
            for (int j = 0; j < frameCount; j++)
            {
                // 一帧一帧的得到对应的顶点数据 存储到VAT中
                // 让对象定格到了某一帧
                animClip.SampleAnimation(targetGameObject, j / animClip.frameRate);
                // 得到当前帧的顶点信息
                // 每次烘焙时把上一次的数据清理了
                mesh.Clear(false);
                // 得到了此时此刻的网格相关的数据
                skinnedMeshRenderer.BakeMesh(mesh);
                // 得到了此时此刻网格对应的所有顶点位置
                Vector3[] vertices = mesh.vertices;
                Vector3[] normals = mesh.normals;
                Vector4[] tangents = mesh.tangents;

                // 我们需要遍历所有动画切片的所有帧的网格顶点位置
                // 从而找到最大最小值 才能进行归一化计算
                // 帧数 * 顶点数
                // (57+50+20)* 9500
                for (int k = 0; k < vertices.Length; k++)
                {
                    Vector3 nowVertice = vertices[k];
                    min = Mathf.FloorToInt(Mathf.Min(min, nowVertice.x, nowVertice.y, nowVertice.z));
                    max = Mathf.CeilToInt(Mathf.Max(max, nowVertice.x, nowVertice.y, nowVertice.z));
                }
            }
        }

        Debug.Log("顶点坐标最小值" + min);
        Debug.Log("顶点坐标最大值" + max);

        Texture2D tex = new Texture2D(2048, 2048, TextureFormat.RGBA32, false, true);
        // 当前像素索引
        int nowPiexlIndex = 0;

        for (int i = 0; i < childAnimatorStateArray.Length; i++)
        {
            AnimatorState state = childAnimatorStateArray[i].state;
            AnimationClip animClip = state.motion as AnimationClip;
            int frameCount = Mathf.CeilToInt(animClip.length * animClip.frameRate);

            // 需要把每一帧对应的时间点传递进去 => 帧数转时间 帧数/帧率
            for (int j = 0; j < frameCount; j++)
            {
                // 一帧一帧的得到对应的顶点数据 存储到VAT中
                // 让对象定格到了某一帧
                animClip.SampleAnimation(targetGameObject, j / animClip.frameRate);
                // 得到当前帧的顶点信息
                // 每次烘焙时把上一次的数据清理了
                mesh.Clear(false);
                // 得到了此时此刻的网格相关的数据
                skinnedMeshRenderer.BakeMesh(mesh);
                // 得到了此时此刻网格对应的所有顶点位置
                Vector3[] vertices = mesh.vertices;
                Vector3[] normals = mesh.normals;
                Vector4[] tangents = mesh.tangents;

                // 我们需要遍历所有动画切片的所有帧的网格顶点位置
                // 从而找到最大最小值 才能进行归一化计算
                // 帧数 * 顶点数
                // (57+50+20)* 9500
                for (int k = 0; k < vertices.Length; k++)
                {
                    Vector3 nowVertice = vertices[k];
                    Vector3 normalVertice = new Vector3(
                        Mathf.InverseLerp(min, max, nowVertice.x),
                        Mathf.InverseLerp(min, max, nowVertice.y),
                        Mathf.InverseLerp(min, max, nowVertice.z)
                    );

                    // 通过索引得到 x y 坐标
                    int x = nowPiexlIndex % 2048;
                    int y = nowPiexlIndex / 2048;
                    tex.SetPixel(x, y, new Color(normalVertice.x, normalVertice.y, normalVertice.z, 1));
                    ++nowPiexlIndex;
                }
            }
        }

        // 循环结束后 像素存储完毕了
        tex.Apply(false, false);
        File.WriteAllBytes(SAVA_VAT_PATH + targetGameObject.name + ".png", tex.EncodeToPNG());
        AssetDatabase.Refresh();

        // 纹理生成完毕了 对它进行一劳永逸的基础设置
        TextureImporter textureImporter = AssetImporter.GetAtPath(SETTING_PATH + targetGameObject.name + ".png") as TextureImporter;
        textureImporter.textureCompression = TextureImporterCompression.Uncompressed;
        textureImporter.filterMode = FilterMode.Point;
        textureImporter.mipmapEnabled = false;
        textureImporter.sRGBTexture = false;
        textureImporter.SaveAndReimport();

        // 之所以绕一圈去获取动画切片 是为了之后做铺垫
        // 我们可以结合Animator,蒙皮网格渲染器,利用他们一起去得到某一个动画某一帧对应的顶点数据
    }
}


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏