6.如何将顶点坐标转换到0~1区间内
6.1 知识点
为何要将顶点坐标转换到0~1区间内
- 目标是把顶点位置数据存进纹理。
- 纹理里每个像素存的是颜色;在 Unity 里往
Color里写时,RGBA各分量用0~1表示。 - 顶点坐标一般不在
0~1里。 - 所以要先做归一化:把某个区间
[min, max]上的数映射到[0, 1]。
如何进行归一化
主要目的
- 把某个区间
[min, max]上的数值映射到[0, 1]。
主要思路
目标值 = (x - min) / (max - min)。- 例如原区间为
[20, 80]:x = 20时:(20 - 20) / (80 - 20) = 0x = 80时:(80 - 20) / (80 - 20) = 1x = 50时:(50 - 20) / (80 - 20) = 0.5
- Unity 里用
Mathf.InverseLerp(a, b, value),内部就是(value - a) / (b - a)。
主要步骤
- 在所有动画、所有帧里扫一遍顶点,得到全局最小值和最大值。
- 再对每个顶点的
x、y、z分别做归一化,然后写入纹理或你自己的缓冲。
下面代码接在第 4 篇的 childAnimatorStateArray、第 5 篇的 SampleAnimation / BakeMesh 之后;开头几行只是把前两篇的取法缩写在同一屏里,避免对着三篇文章跳行。示例里 x、y、z 共用一对 min / max;若 max == min,InverseLerp 会除零,工具里要单独处理。
第一段:统计 min、max,Debug.Log 打边界。
using UnityEngine;
using UnityEditor.Animations;
//1.得到目标GameObject
//(由外部 targetGameObject 传入)
//2.得到GameObject上的Animator组件
Animator animator = targetGameObject.GetComponent<Animator>();
if (animator == null)
{
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>();
Mesh mesh = new Mesh();
//用于存储顶点最大最小值的变量
int min = int.MaxValue;
int max = int.MinValue;
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多少帧
//需要把每一帧对应的时间点传递进去 => 帧数转时间 帧数/帧率
for (int j = 0; j < frameCount; j++)
{
//一帧一帧的得到对应的顶点数据 存储到VAT中
//让对象定格到了某一帧
animationClip.SampleAnimation(targetGameObject, j / animationClip.frameRate);
//得到当前帧的顶点信息
//每次烘焙时把上一次的数据清理了
mesh.Clear(false);
//得到了此时此刻的网格相关的数据
skinnedMeshRenderer.BakeMesh(mesh);
//得到了此时此刻网格对应的所有顶点位置
// 获取网格的顶点数组:每个顶点存储了三维空间中的坐标(x,y,z)
Vector3[] vertices = mesh.vertices;
// 获取网格的法线数组:每个顶点的法线方向,用于计算光照效果
Vector3[] normals = mesh.normals;
// 获取网格的切线数组:用于法线贴图(凹凸贴图)的计算,是四维向量(x,y,z,w),w 用于控制切线方向
Vector4[] tangents = mesh.tangents;
//我们需要遍历所有动画切片的所有帧的网格顶点位置
//从而找到最大最小值 才能进行归一化计算
//帧数 * 顶点数
//(57+50+20)* 9500
// 遍历网格的所有顶点,计算顶点坐标的最小/最大整数值
for (int k = 0; k < vertices.Length; k++)
{
// 获取当前遍历到的顶点的三维坐标(x,y,z)
Vector3 vertice = vertices[k];
// 1. 更新最小值:
// - 对比当前min值、顶点x/y/z坐标,取其中最小的数值
// - Mathf.FloorToInt:向下取整(比如 2.9 → 2,-1.2 → -2),确保得到整数边界
min = Mathf.FloorToInt(Mathf.Min(min, vertice.x, vertice.y, vertice.z));
// 2. 更新最大值:
// - 对比当前max值、顶点x/y/z坐标,取其中最大的数值
// - Mathf.CeilToInt:向上取整(比如 2.1 → 3,-1.9 → -1),确保得到整数边界
max = Mathf.CeilToInt(Mathf.Max(max, vertice.x, vertice.y, vertice.z));
}
}
}
Debug.Log("顶点坐标最小值" + min);
Debug.Log("顶点坐标最大值" + max);
第一段跑完后,Console 里会有两行边界。课程工程里示例如下(具体数字随资源而变):

第二段:用上面算好的 min、max,再套同样的双重循环,把每个顶点压到 0~1。
for (int i = 0; i < childAnimatorStateArray.Length; i++)
{
AnimatorState animatorState = childAnimatorStateArray[i].state;
AnimationClip animationClip = animatorState.motion as AnimationClip;
int frameCount = Mathf.CeilToInt(animationClip.length * animationClip.frameRate);
//需要把每一帧对应的时间点传递进去 => 帧数转时间 帧数/帧率
for (int j = 0; j < frameCount; j++)
{
//一帧一帧的得到对应的顶点数据 存储到VAT中
//让对象定格到了某一帧
animationClip.SampleAnimation(targetGameObject, j / animationClip.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++)
{
// 获取当前索引k对应的顶点三维坐标(x/y/z)
Vector3 vertice = vertices[k];
// 将顶点的三维坐标从 [min, max] 范围归一化到 [0, 1] 区间(核心:逆线性插值)
// 归一化后的坐标存储到 normalVertice,用于后续颜色映射/纹理绘制等操作
Vector3 normalVertice = new Vector3(
Mathf.InverseLerp(min, max, vertice.x), // 对顶点x坐标做归一化
Mathf.InverseLerp(min, max, vertice.y), // 对顶点y坐标做归一化
Mathf.InverseLerp(min, max, vertice.z) // 对顶点z坐标做归一化
);
}
}
}
6.2 知识点代码
NormalizeVerticesForVatExample.cs
using UnityEngine;
using UnityEditor.Animations;
// 将本脚本放在项目的 Editor 目录下,并确保程序集引用了 UnityEditor
public static class NormalizeVerticesForVatExample
{
public static void ExampleNormalizeVertices(GameObject targetGameObject)
{
if (targetGameObject == null)
{
return;
}
//我们的目标是要把顶点位置数据存储到纹理图片中
//纹理图片中每一个像素是一个颜色信息,在Unity中颜色信息中的RGBA分量需要用0~1表示
//而我们的顶点位置显然不在0~1的范围内
//因此我们需要对顶点坐标进行归一化操作
//即把某个区间[min,max]的数值,映射到[0,1]区间内
//主要目的:
//把某个区间[min,max]的数值,映射到[0,1]区间内
//主要思路:
//目标值 = (x - min) / (max - min)
//比如:
//原区间为[20,80]
// x = 20时
// (20 - 20) / (80 - 20) = 0
// x = 80时
// (80 - 20) / (80 - 20) = 1
// x = 50时
// (50 - 20) / (80 - 20) = 0.5
//Unity自带API
//Mathf.InverseLerp(a, b, value);
//该函数内部就是利用该公式进行计算的 返回值为 (value - a) / (b - a)
//主要步骤:
//1.得到所有动画中顶点坐标的最小值和最大值
//2.对每个顶点的位置进行归一化后再存储
// —— 第 4 篇:Animator → AnimatorController → 状态数组 ——
Animator animator = targetGameObject.GetComponent<Animator>();
if (animator == null)
{
return;
}
AnimatorController animatorController = animator.runtimeAnimatorController as AnimatorController;
if (animatorController == null)
{
return;
}
AnimatorStateMachine animatorStateMachine = animatorController.layers[0].stateMachine;
ChildAnimatorState[] childAnimatorStateArray = animatorStateMachine.states;
// —— 第 5 篇:蒙皮网格 + Bake 用的 Mesh ——
SkinnedMeshRenderer skinnedMeshRenderer = targetGameObject.GetComponentInChildren<SkinnedMeshRenderer>();
if (skinnedMeshRenderer == null)
{
return;
}
Mesh mesh = new Mesh();
//用于存储顶点最大最小值的变量
int min = int.MaxValue;
int max = int.MinValue;
for (int i = 0; i < childAnimatorStateArray.Length; i++)
{
AnimatorState animatorState = childAnimatorStateArray[i].state;
AnimationClip animationClip = animatorState.motion as AnimationClip;
if (animationClip == null)
{
continue;
}
// animationClip.frameRate 帧率
// animationClip.length 时长
int frameCount = Mathf.CeilToInt(animationClip.frameRate * animationClip.length); //得到这个clip多少帧
//需要把每一帧对应的时间点传递进去 => 帧数转时间 帧数/帧率
for (int j = 0; j < frameCount; j++)
{
//一帧一帧的得到对应的顶点数据 存储到VAT中
//让对象定格到了某一帧
animationClip.SampleAnimation(targetGameObject, j / animationClip.frameRate);
//得到当前帧的顶点信息
//每次烘焙时把上一次的数据清理了
mesh.Clear(false);
//得到了此时此刻的网格相关的数据
skinnedMeshRenderer.BakeMesh(mesh);
//得到了此时此刻网格对应的所有顶点位置
// 获取网格的顶点数组:每个顶点存储了三维空间中的坐标(x,y,z)
Vector3[] vertices = mesh.vertices;
// 获取网格的法线数组:每个顶点的法线方向,用于计算光照效果
Vector3[] normals = mesh.normals;
// 获取网格的切线数组:用于法线贴图(凹凸贴图)的计算,是四维向量(x,y,z,w),w 用于控制切线方向
Vector4[] tangents = mesh.tangents;
//我们需要遍历所有动画切片的所有帧的网格顶点位置
//从而找到最大最小值 才能进行归一化计算
//帧数 * 顶点数
//(57+50+20)* 9500
// 遍历网格的所有顶点,计算顶点坐标的最小/最大整数值
for (int k = 0; k < vertices.Length; k++)
{
// 获取当前遍历到的顶点的三维坐标(x,y,z)
Vector3 vertice = vertices[k];
// 1. 更新最小值:
// - 对比当前min值、顶点x/y/z坐标,取其中最小的数值
// - Mathf.FloorToInt:向下取整(比如 2.9 → 2,-1.2 → -2),确保得到整数边界
min = Mathf.FloorToInt(Mathf.Min(min, vertice.x, vertice.y, vertice.z));
// 2. 更新最大值:
// - 对比当前max值、顶点x/y/z坐标,取其中最大的数值
// - Mathf.CeilToInt:向上取整(比如 2.1 → 3,-1.9 → -1),确保得到整数边界
max = Mathf.CeilToInt(Mathf.Max(max, vertice.x, vertice.y, vertice.z));
}
}
}
Debug.Log("顶点坐标最小值" + min);
Debug.Log("顶点坐标最大值" + max);
for (int i = 0; i < childAnimatorStateArray.Length; i++)
{
AnimatorState animatorState = childAnimatorStateArray[i].state;
AnimationClip animationClip = animatorState.motion as AnimationClip;
if (animationClip == null)
{
continue;
}
int frameCount = Mathf.CeilToInt(animationClip.length * animationClip.frameRate);
//需要把每一帧对应的时间点传递进去 => 帧数转时间 帧数/帧率
for (int j = 0; j < frameCount; j++)
{
//一帧一帧的得到对应的顶点数据 存储到VAT中
//让对象定格到了某一帧
animationClip.SampleAnimation(targetGameObject, j / animationClip.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++)
{
// 获取当前索引k对应的顶点三维坐标(x/y/z)
Vector3 vertice = vertices[k];
// 将顶点的三维坐标从 [min, max] 范围归一化到 [0, 1] 区间(核心:逆线性插值)
// 归一化后的坐标存储到 normalVertice,用于后续颜色映射/纹理绘制等操作
Vector3 normalVertice = new Vector3(
Mathf.InverseLerp(min, max, vertice.x), // 对顶点x坐标做归一化
Mathf.InverseLerp(min, max, vertice.y), // 对顶点y坐标做归一化
Mathf.InverseLerp(min, max, vertice.z) // 对顶点z坐标做归一化
);
}
}
}
}
}
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com