11.具体实现-烘焙部分-编辑器功能入口
11.1 知识点
为何要创建编辑器入口
- 之前几篇补充知识中,我们是分散地学习了如何获取动画信息、如何烘焙顶点数据、如何归一化、如何写入纹理。
- 现在需要把这些零散的步骤整合成一个完整的工具。
- 通过编辑器菜单入口,可以方便地选中目标对象后一键生成 VAT。
如何创建编辑器入口
主要目的
- 创建一个编辑器工具,选中目标对象后通过菜单一键生成 VAT 纹理。
主要思路
- 使用
[MenuItem]特性创建菜单入口。 - 通过
Selection.activeObject获取当前选中的对象。 - 整合之前学习的所有步骤:获取动画信息 → 烘焙顶点 → 归一化 → 写入纹理 → 设置导入参数。
- 添加必要的容错校验。
主要步骤
- 定义存储路径常量,方便统一管理。
- 创建菜单入口,获取选中的目标对象。
- 校验目标对象是否具备必要的组件(Animator、SkinnedMeshRenderer)。
- 获取动画切片信息。
- 遍历所有动画帧,统计顶点坐标的最小值和最大值。
- 再次遍历,将归一化后的顶点坐标写入纹理。
- 保存 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 篇补充知识。
- 初始化
min为int.MaxValue,max为int.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