16.总结
16.1 知识点

总结主要内容

主要学习内容


更多拓展

举一反三

总结

16.2 核心要点速览
压测基线与资源准备
整套对比建立在 Built-in 管线上:同一空工程里导入角色和动画,后面 VAT 和 GPU Instancing 的优化都针对「大量相同角色、动作同步播放」这种压力场景。中途换成 URP 或改场景结构,前面记的 Batches、Profiler 曲线就对不上了,所以基线阶段先把管线钉死。
- 模型导入:
Model里该开的Import BlendShapes、Import Visibility要开全,否则播动画时表情或显隐层级会丢。待机动画用的AnimationClip建议勾Loop Time,Profiler 里看到的是稳定循环,而不是播完卡一下再从头。 - 统计入口:
Game视图Stats看Batches、SetPass Calls、Triangles;Profiler里盯GPU、Rendering。这组数字是后面优化的参照,场景布局一变就要重新采一版。
| 项 | 做法 | 说明 |
|---|---|---|
| 控制器 | 在 Resources/Anim 下放 Animator Controller,默认状态指到待机动画 |
Resources 只是教程写法,项目里可换成 Addressables 等,只要批量实例读得到同一套控制器 |
| 预制体 | 场景里摆好 Animator、缩放和朝向,再拖到 Resources/Prefabs |
后面 Instantiate 都从这个预制体来,避免手改场景对象和脚本生成的不一致 |
| 批量生成 | CreateObject 用双层循环在 XZ 平面上铺实例,父节点挂在 ObjectFather 下 |
脚本里字段名叫 x、y,实际表示 X 方向个数和 Z 方向个数,和 Vector3 的 y 轴不是一回事 |
| 看数据 | 同上 Stats + Profiler |
改预制体、材质、实例数量之前先记一版,方便对比 |
// 外层 i 沿 X,内层 j 沿 Z;注意字段 y 是「Z 向数量」不是竖直高度
for (int i = 0; i < x; i++)
for (int j = 0; j < y; j++)
Instantiate(obj, father.transform).transform.position = new Vector3(i, 0, j);
从动画机到 VAT 像素
枚举 Clip 与帧数
运行时 Animator 上的 runtimeAnimatorController 强转成 AnimatorController,取 layers[0].stateMachine.states 得到 ChildAnimatorState[],再把每个 state.motion 转成 AnimationClip 才能进烘焙循环。motion 也可能是 BlendTree 或别的类型,教程正文只写了纯 AnimationClip 分支,真项目里要按类型拆开处理。
帧数用 Mathf.CeilToInt(clip.frameRate * clip.length) 估一个上限,后面按帧采样时不会少采。
定帧取顶点
对第 j 帧:SampleAnimation(gameObject, j / frameRate) 先把骨骼姿势写到层级上,再 mesh.Clear(false) 清掉上一帧残留,然后 SkinnedMeshRenderer.BakeMesh(mesh),从 mesh.vertices、normals、tangents 读出这一帧的蒙皮结果。顺序不能反,BakeMesh 采到的是「当前姿势下」的网格。
角色身上不止一块蒙皮时,每个 SkinnedMeshRenderer 各自烘焙;VAT 里要么分区域写进同一张图,要么拆多张贴图,工具侧要约定好对应关系。
归一化进纹理
第一遍遍历所有 Clip、所有帧、所有顶点,用 Mathf.FloorToInt / CeilToInt 把世界空间分量收进一套全局 min、max。第二遍再对每个分量做 Mathf.InverseLerp,压到 0~1 后塞进 Color 写像素。某一轴 max == min 时要单独分支,避免 InverseLerp 除零。
写 PNG 与导入设置
| 步骤 | 要点 |
|---|---|
| 建纹理 | RGBA32、关 mipmap;Texture2D 构造函数里线性空间那个参数用 true,别把数据当 sRGB 再弯一次 |
| 写盘 | SetPixel 之后必须 Apply,再 EncodeToPNG + File.WriteAllBytes |
| 导入 | AssetDatabase.Refresh 后对贴图跑 TextureImporter:Uncompressed、FilterMode.Point、关 mipmap、关 sRGBTexture,最后 SaveAndReimport |
| 像素坐标 | x = index % 宽度,y = index / 宽度,注意别写出纹理边界 |
下面示例只把顶点位置塞进 RGB。法线、切线如果要跟变形走,一般要把 [-1,1] 再映射到 [0,1],而且经常要单独一张数据纹理,和位置 VAT 分开存。
| API 或类型 | 干什么 | 容易踩的地方 |
|---|---|---|
AnimatorController、ChildAnimatorState |
从运行时控制器反查编辑器里的状态机 | 控制器为空、motion 不是 AnimationClip 时要跳过 |
AnimationClip.SampleAnimation |
按时间把姿势写到对象层级 | 骨骼层级、SkinnedMeshRenderer 要和 Clip 绑定关系一致 |
SkinnedMeshRenderer.BakeMesh |
把当前帧蒙皮结果拷进目标 Mesh |
必须出现在 SampleAnimation 之后 |
Mathf.InverseLerp |
把分量归一化到 0~1 |
教程里三轴共用一对 min/max 是省事写法,精度和语义要不要分轴,按项目自己定 |
TextureImporter 那一套 |
保证读回的值就是写进去的数 | 压缩、双线性、伽马都会把数据弄脏;编辑器里看起来像彩噪点很正常 |
animationClip.SampleAnimation(targetGameObject, j / animationClip.frameRate);
mesh.Clear(false);
skinnedMeshRenderer.BakeMesh(mesh);
Vector3[] vertices = mesh.vertices;
texture2D.SetPixel(x, y, new Color(nx, ny, nz, 1f));
texture2D.Apply(false, false);
File.WriteAllBytes(path, texture2D.EncodeToPNG());
Shader 实例化、顶点 ID 与 MPB
启用 Instancing
#pragma multi_compile_instancing 打开之后,在 appdata 和 v2f 里声明 UNITY_VERTEX_INPUT_INSTANCE_ID,顶点着色器一进来先 UNITY_SETUP_INSTANCE_ID。如果片元里还要读逐实例属性,就在 vert 里 UNITY_TRANSFER_INSTANCE_ID,frag 开头再 UNITY_SETUP_INSTANCE_ID 一次。
物体空间到裁剪空间用 UnityObjectToClipPos,不要写老的 mul(UNITY_MATRIX_MVP, …),变体和新管线都对不上。
逐实例数据与 VAT
UNITY_INSTANCING_BUFFER_START / UNITY_DEFINE_INSTANCED_PROP / UNITY_ACCESS_INSTANCED_PROP 这一套,在 Shader 里声明的名字要和 C# 侧 MaterialPropertyBlock.SetVector、SetFloat 等对得上,否则实例化批次里参数是乱的。
VAT 这边在顶点输入上加 uint vid : SV_VertexID。它表示当前这一次 DrawCall 提交的顶点流里的序号,只有和烘焙时写纹理用的顶点顺序一致,后面用 vid 去算纹素位置才有意义。
MaterialPropertyBlock 实际干什么
MaterialPropertyBlock 解决的是「不想为每个物体 new Material,又要改这个 Renderer 上的参数」。单靠它并不能减少 DrawCall;要和开启了 GPU Instancing、并且在 UNITY_INSTANCING_BUFFER 里声明了逐实例字段的 Shader 配合,才能在同一材质、不同参数的情况下仍然走实例化那条路径。
| 宏或 API | 放在哪 | 备注 |
|---|---|---|
UNITY_SETUP_INSTANCE_ID |
vert 开头必调;frag 要用实例属性时也要 |
没设好实例 ID,UNITY_ACCESS_INSTANCED_PROP 读出来没意义 |
UNITY_TRANSFER_INSTANCE_ID |
片元需要逐实例数据时,在 vert 里传下去 |
顶点阶段算完就扔、片元不读,可以不传 |
SV_VertexID |
VAT 里算纹素偏移 | 和 Mesh 顶点顺序、SubMesh、合批方式强相关,换网格要重新对表 |
GetPropertyBlock / SetPropertyBlock |
改 MPB 前先 Get 再改再 Set,避免把别的系统写的参数冲掉 |
属性 ID 用 Shader.PropertyToID 缓存 |
编辑器 VAT 工具入口
菜单 [MenuItem("VAT Tool/CreateVAT")] 里用 Selection.activeObject as GameObject 拿到当前选中的根节点,后面的烘焙逻辑都从这个根往下走。脚本放在 Editor 目录,引用 UnityEditor,否则会把编辑器 API 编进 Player,构建或运行阶段直接报错。
写文件和走导入器要用两套路径字符串对上:
| 用途 | 路径 |
|---|---|
File.WriteAllBytes |
Application.dataPath + "/Art/VAT/" |
AssetImporter.GetAtPath |
Assets/Art/VAT/ |
目标目录要先建好。选中为空、没有 Animator、runtimeAnimatorController 转不成 AnimatorController、下面找不到 SkinnedMeshRenderer,这类情况直接 LogError 然后 return,比烘出一半坏数据要好查得多。
| 检查点 | 为什么看它 |
|---|---|
| 选中对象 | 定死烘焙根,避免烘到场景里别的角色 |
Animator |
没有它就拿不到 AnimatorController 和状态列表 |
SkinnedMeshRenderer |
BakeMesh 的数据来源 |
VAT 纹理排布与 ScriptableObject 配置
贴图里像素的排列顺序,和 AnimatorController 里状态机的遍历顺序一致:先把动画 A 的「每一帧 × 每一个顶点」连续写满,再紧挨着写 B、再写 C。宽度固定(教程里用 2048),按行优先填满。整张图占用的像素总数等于各段动画的 帧数 × 顶点数 之和。一张不够就拆多张 VAT,教程示例单张能装下就没展开。
| 类型 | 字段在记什么 |
|---|---|
AnimationInfo,一段动画 |
animationName 和 AnimatorState.name 对齐;frameCount;这段数据在 VAT 里从哪个 pixelIndex 开始;frameRate |
AnimationInfos,整张 VAT |
vertexCount 一般取 sharedMesh.vertexCount;vertexMin / vertexMax 用来在 Shader 里反映射;vat_texture 引用烘焙出来的贴图;allAnimationinfo 装所有段的元数据 |
每写完一段,游标前进 frameCount * vertexCount,和 SetPixel 时 nowPixelIndex 往前走的长度一致,运行时 pixelIndex 才不会指到别人的数据上。
烘焙收尾与资源关联
| 遍次 | 内容 |
|---|---|
| 第一遍 | 只算全局 min / max,不写像素 |
| 第二遍 | 一边 SetPixel 一边把对应 AnimationInfo 的 pixelIndex、帧数、帧率填好,保证 .asset 和 .png 是同一次烘焙出来的 |
PNG 导入设置还是前面那套数据贴图流程。用 AssetDatabase.LoadAssetAtPath<Texture2D> 赋给 ScriptableObject 的 vat_texture,AssetDatabase.CreateAsset 写出 xxx_animationInfos.asset,刷新工程后材质球上直接拖引用即可。
VAT Shader 采样与运行时驱帧
Shader 与材质参数分工
示例 Shader 挂在 Unlit/VATShader 上,在已经能跑 Instancing 的版本里换成 UNITY_INSTANCING_BUFFER_START(VAT),里面 _FrameRate、_OffsetIndex、_PixelIndex、_FrameCount 用 MaterialPropertyBlock.SetInt 从 C# 灌进去,和每条 AnimationInfo 对上。_VertexCount、_VertexMax、_VertexMin 全模型共用,用 Material.SetInt 写一次就行。
索引、采样与顶点输出
顶点函数里拿 uint vid : SV_VertexID,用下面这段 HLSL 把当前帧、当前顶点映射到 VAT 上的 uv,tex2Dlod 读出归一化位置,再 vertexPos * (max - min) + min 反映射回物体空间,写回 appdata.vertex,最后走 UnityObjectToClipPos。
float index = pixelIndex
+ floor(fmod(_Time.y * frameRate + offsetIndex, frameCount)) * _VertexCount
+ vid;
float2 uv = float2(
fmod(index, _VAT_Tex_TexelSize.z) * _VAT_Tex_TexelSize.x,
(index * _VAT_Tex_TexelSize.x) * _VAT_Tex_TexelSize.y);
float3 vertexPos = tex2Dlod(_VAT_Tex, float4(uv, 0, 0));
vertexPos = vertexPos * (_VertexMax - _VertexMin) + _VertexMin;
运行时网格与播放
海量实例这条路上继续用 SkinnedMeshRenderer 播骨骼,和常见的 GPU Instancing 流程很难拧在一起。做法是换成 MeshRenderer + MeshFilter,网格拓扑和烘焙时 BakeMesh 的那份一致,材质勾选 GPU Instancing,顶点动画完全交给 VAT 在着色器里算。
Play(name, isRandom) 按名字在 AnimationInfos 里查到对应的 AnimationInfo。所有实例相同的量用 Material.SetInt 等写在材质上;每个 Renderer 独有的量走 MaterialPropertyBlock,例如给 _OffsetIndex 随机一个初值把动作相位错开,最后对该 Renderer 调用 SetPropertyBlock。
| 点 | 说明 |
|---|---|
| 逐帧采样 | 没有插值,镜头贴脸会看到跳变;要平滑只能加帧率或 Shader 里做插值 |
| 没有 Animator 状态机 | 切动画、混合、过渡逻辑要自己写,不能像蒙皮那样拖状态机完事 |
| VAT 体积 | 顶点多、动画长,单张 2048 不够就拆多张贴图或走数组,Shader 里索引方式要跟着改 |
| 法线、切线 | 本教程顶点阶段只动了位置,正确光照往往还要额外烘焙法线数据或单独方案 |
| 阴影 | 自定义 Shader 通常要自己补 ShadowCaster / DepthOnly 之类 Pass,否则投影或深度不对 |
16.3 面试题精选
进阶题
1. SampleAnimation 和 BakeMesh 的调用顺序与前提是什么?
题目
用 VAT 烘焙管线说明:为什么先 SampleAnimation 再 BakeMesh?对场景里的对象结构有什么要求?
深入解析
SampleAnimation把指定时间点的骨骼姿势写到GameObject子层级,SkinnedMeshRenderer在该姿势下算出蒙皮后的顶点。BakeMesh只负责把「当前帧已经算好的蒙皮结果」拷进目标Mesh;若不先采样,烘焙到的是上一帧或默认姿势。- 前提:
targetGameObject上挂的Animator/ 骨骼层级与AnimationClip一致,子层级找得到匹配的SkinnedMeshRenderer;motion必须是能采样的AnimationClip,BlendTree等需另写分支。
答题示例
先 SampleAnimation 定姿势,再 BakeMesh 把该姿势下的蒙皮顶点落到 Mesh 里。
骨骼层级和蒙皮网格要跟 Clip 对得上,否则采样无效或顶点不对。
参考文章
- 5.补充知识-如何获取某个动画中所有帧的顶点信息
2. VAT 用的 PNG 为什么要 Uncompressed、Point、关闭 sRGB?
题目
TextureImporter 里这几项如果设错,VAT 会出什么问题?
深入解析
- 压缩格式会丢失或量化通道值,顶点还原不再可靠。
Bilinear/Trilinear会在采样时混合邻像素,破坏「一像素一份数据」的布局。sRGB打开会做伽马相关转换,把本来当线性数据存的 RGB 再弯一次,解码顶点时偏移。- mipmap 会生成缩小链,VAT 通常按固定分辨率逐像素索引,不需要 mip。
答题示例
这是数据纹理不是贴图,压缩和过滤都会改像素值,sRGB 还会多一次非线性变换。
所以关压缩、用 Point、关 sRGB、关 mipmap,保证读回的值就是写进去的归一化坐标。
参考文章
- 7.补充知识-如何将颜色信息存储到纹理中
3. GPU Instancing 相关宏在顶点和片元里怎么配合?
题目
UNITY_SETUP_INSTANCE_ID、UNITY_TRANSFER_INSTANCE_ID 分别在什么阶段必须调?片元里什么时候还要再调一次?
深入解析
#pragma multi_compile_instancing生成变体后,实例 ID 由运行时注入;UNITY_SETUP_INSTANCE_ID在着色器入口把当前实例上下文准备好,顶点阶段访问 instanced 属性前必须先执行。- 若片元着色器要读
UNITY_ACCESS_INSTANCED_PROP,需要把实例 ID 从顶点传到片元:UNITY_TRANSFER_INSTANCE_ID写在vert里,并在frag开头再UNITY_SETUP_INSTANCE_ID。 - 顶点里不用实例属性、只在片元用贴图采样时,仍要看是否触及 per-instance 数据决定传递链。
答题示例
vert 里最先 SETUP,需要把实例 ID 带到片元就 TRANSFER。
frag 里如果要用 per-instance 属性,再 SETUP 一次接上上下文。
参考文章
- 8.补充知识-如何让自定义Shader支持GPUInstancing
4. VAT 里 pixelIndex 和状态机动画顺序是什么关系?
题目
AnimationInfo.pixelIndex 是怎么来的?换动画顺序不重新烘焙会怎样?
深入解析
- 烘焙按
AnimatorStateMachine.states的顺序依次写像素:每一段从当前nowAnimationIndex(或等价游标)开始写frameCount × vertexCount个像素,并把该值记入AnimationInfo.pixelIndex。 - 段末游标增加
frameCount * vertexCount,下一段紧接其后;因此状态机在控制器里的顺序就是纹理里的空间顺序。 - 只改控制器里状态顺序而不重烘焙,会导致
pixelIndex、帧数与纹素内容错位,运行时采样到错误动画或乱顶点。
答题示例
pixelIndex 是这一段动画数据在 VAT 里的起始像素下标,按状态机遍历顺序连续排布。
改状态顺序不重烘焙,配置和纹理对不上,动画就废了。
参考文章
- 12.具体实现-烘焙部分-动画信息配置
- 13.具体实现-烘焙部分-生成配置信息
5. 为什么 VAT + Instancing 要用 MeshRenderer 而不是 SkinnedMeshRenderer?
题目
蒙皮角色本来用 SkinnedMeshRenderer,这一实践为什么换成静态网格 + VAT?
深入解析
- 课文明确:骨骼蒙皮那条渲染路径与「海量实例 + GPU Instancing」的常见用法不匹配,目标是让顶点位置完全由 VAT 在着色器里驱动,CPU 不再逐实例更新骨骼。
MeshRenderer+MeshFilter使用静态网格拓扑,顶点顺序与烘焙BakeMesh一致,便于SV_VertexID对齐纹素;Instancing 批量时材质与缓冲统一。- 代价是失去 Animator 状态机与骨骼插值,切动画、混合、IK 等要自己在逻辑或 Shader 里补。
答题示例
SkinnedMeshRenderer 走骨骼更新,不适合本课要的 Instancing 大批量套路。
换成 MeshRenderer 挂静态网格,顶点动画完全由 VAT 在 GPU 里算,才能大批量合批。
参考文章
- 15.具体实现-渲染部分-CSharp传参与动画播放实现
深度题
1. MaterialPropertyBlock 能单独降低 DrawCall 吗?和 Instancing 怎么配合?
题目
有人说「用 MPB 就能批量渲染」,你怎么纠正?正确用法是什么?
深入解析
- MPB 解决的是不克隆材质前提下覆写
Renderer的参数,省材质实例和内存;每次 Set 的是「这个 Renderer 的材质属性覆盖层」,不自动合并 DrawCall。 - 正文结论:要和 Shader 里
UNITY_INSTANCING_BUFFER声明的 per-instance 属性对齐,由同一兼容 Instancing 的材质批量绘制,实例间差异走 instanced prop + MPB,才能在不打断批的前提下做「同材质不同参数」。 - 若 Shader 未启用 Instancing 或未走相同 batch 条件,MPB 仍可能打断合批,需结合 Frame Debugger 验证。
答题示例
MPB 本身不减少 DrawCall,它避免为每个对象 new 材质。
和启用了 GPU Instancing 的 Shader、以及 instanced 属性字段配合,才能让不同参数仍算在同一类实例化绘制里。
参考文章
- 10.补充知识-如何使用MaterialPropertyBlock
- 8.补充知识-如何让自定义Shader支持GPUInstancing
2. 用 SV_VertexID 读 VAT 时,和烘焙阶段的数据要满足什么一致条件?
题目
VAT 贴图里按顶点顺序排像素,运行时 Shader 里用 SV_VertexID 取数,可能踩哪些坑?
深入解析
- 烘焙时
mesh.vertices的顺序与运行时提交给 GPU 的顶点流顺序一致时,vid才能与纹素一一对应;LOD、动态合批、不同 Mesh 合并都会改顺序。 - VAT 布局若按「帧 × 顶点」或「顶点 × 帧」展开,片元或顶点里还要乘上帧偏移、实例偏移,与 C# 侧
MaterialPropertyBlock传的索引一致。 - 多 SubMesh、多材质槽时,DrawCall 拆分后顶点流可能不是整张 VAT 假设的那一段,需要在工程里约定「只对单一 SubMesh、单一 Pass 的 VAT 网格」使用。
答题示例
VertexID 对应当前 Draw 的顶点序号,必须和烘焙时写纹理用的顶点顺序一致。
换 Mesh、合批、改 LOD 都可能让序号对不上,要在工具里固定拓扑和提交顺序。
参考文章
- 9.补充知识-如何在Shader中区分不同顶点
- 5.补充知识-如何获取某个动画中所有帧的顶点信息
3. VAT 顶点着色器里 index 公式的每一项,在 C# 里谁负责传?
题目
pixelIndex + floor(fmod(_Time.y * frameRate + offsetIndex, frameCount)) * _VertexCount + vid 中,哪些来自 AnimationInfos / AnimationInfo,哪些是 Unity 内置,哪些适合用 MPB?
深入解析
vid由 GPU 根据当前 Draw 的顶点流给出,无需 C# 传。_Time.y为 Shader 内置时间;frameRate、offsetIndex、pixelIndex、frameCount在课文中通过MaterialPropertyBlock.SetInt按实例写入,与AnimationInfo的frameRate、pixelIndex、frameCount及随机相位offsetIndex对应。_VertexCount、_VertexMax、_VertexMin来自AnimationInfos,课文用material.SetInt作为各实例共享数据。- 公式与
nowAnimationIndex烘焙布局一致,才能保证pixelIndex与纹素对齐。
答题示例
vid 是 GPU 给的顶点序号;时间在 Shader 里。
帧率、起始像素、总帧、随机偏移用 MPB 从 AnimationInfo 填;顶点数和 min、max 用 Material 写共享的 AnimationInfos。
参考文章
- 14.具体实现-渲染部分-Shader得到VAT数据并赋值
- 15.具体实现-渲染部分-CSharp传参与动画播放实现
4. 课文里 VAT 方案的五条「遗留问题」,你会怎么跟策划或引擎组对齐?
题目
不流畅、切换麻烦、多纹理、法线、阴影 Pass——各用什么工程手段缓解?
深入解析
- 不流畅:提高烘焙帧率、Shader 双帧插值、远景 VAT / 近景蒙皮分级。
- 切换与循环:自管片段与首尾对齐,
Play关闭随机偏移从首帧播;需要过渡时自写短混合。 - 多纹理:按容量拆多张 VAT 或纹理数组,SO 与 Shader 增加纹理索引与 UV 换算。
- 法线切线:额外烘焙数据纹理或简化光照;主角单独高精度方案。
- 阴影深度:为自定义 Shader 补
ShadowCaster/DepthOnly等 Pass,或用 Layer 与渲染特征与蒙皮物体区分。
答题示例
近看跳就加插值或加采样率;切动画没有状态机就自己封一层逻辑。
数据装不下就拆多张 VAT;要正确光照就补法线烘焙;要投影就自己加阴影 Pass。
参考文章
- 15.具体实现-渲染部分-CSharp传参与动画播放实现
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com