尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

独立游戏GPU动画实战:顶点动画纹理与实例化渲染

独立游戏GPU动画实战:顶点动画纹理与实例化渲染 独立游戏角色制作走到GPU动画这一步很大程度是因为角色数量一旦多起来传统骨骼动画的CPU开销就非常显眼。这里说的GPU动画不是指引擎里的动画后处理也不是简单的贴图UV滚动而是把角色动画数据从CPU的骨骼矩阵更新里解放出来挪到GPU端去采样和计算让大量角色能够更低成本地同时播放动画。这篇我会把角色模型怎么准备好、动画数据怎么烘焙、Shader怎么写、实例化怎么配合、性能怎么验证这些环节完整拆一遍。适合做RPG、策略、模拟经营这类同屏角色多又不想引入重引擎动画框架的独立游戏开发者。1. 先确认GPU动画到底解决什么问题1.1 骨骼动画在角色数量变多后的真实瓶颈传统骨骼动画流程里CPU每一帧要遍历骨骼层级计算每个骨骼的局部变换、世界变换然后把骨骼矩阵上传到GPU。角色数量少比如主角加几个NPC这个开销很小。可一旦场景里有几十上百个角色比如RTS里的战斗单位、模拟经营里的居民、割草类游戏里的敌人CPU端的动画更新就会变成很扎眼的开销。即使角色模型本身只有几千个顶点骨骼动画的CPU开销依然存在。这个瓶颈不是单纯的“画得多”而是“要更新的骨骼多”。另外一个容易忽略的点是合批和实例化。骨骼动画通常会让模型被骨骼矩阵驱动不同角色的矩阵数据不一致Draw Call很难降下来。GPU动画把动画数据放到贴图里每个顶点在顶点着色器里采样自己的位置骨骼矩阵不参与驱动渲染层面前提就变了。这就是为什么很多需要在屏幕上出现大量角色、又没有专门优化团队的项目会考虑GPU动画。1.2 GPU动画做的事情到底是什么从工作分工上看GPU动画把“动画驱动”从CPU端挪到GPU端。CPU不需要遍历骨骼只需要提交角色实例的变换、动画时间、动画ID之类很薄的数据。GPU在顶点着色器里根据当前时间和顶点索引从一张预烘焙的动画纹理里读出这个顶点在当前帧的位置和法线再参与后续模型变换和光照计算。这里要区分两类常见实现一类是顶点动画纹理把顶点位置和法线按“顶点行时间列”存成纹理另一类是把动画烘焙成顶点序列帧然后每帧切换顶点索引。实际里我更多用顶点动画纹理因为它的数据密度和采样灵活性更高而且可以配合实例化。1.3 适合GPU动画的场景和反例适合的场景一般有三个特征角色模型顶点数不高几千到一两万以内。角色数量多几十到几百。动画数量相对受控比如待机、走路、跑步、攻击、死亡这些循环动作。如果反过来了角色是单个主角或者要做大量骨骼绑定和姿态融合、技能驱动、物理交互那GPU动画的价值就会缩水。你的动画数量很多、每个动画很长烘焙后的贴图内存就会大如果角色要频繁换装备或者换体型顶点动画纹理的数据复用也很麻烦。这里没有绝对优劣关键是先确认项目里“角色多”到底是不是一个主要矛盾。2. 我选的实现路线顶点动画纹理2.1 为什么选顶点动画纹理而不是骨骼矩阵贴图实现GPU动画还有另一种方案把骨骼矩阵烘焙到纹理里GPU采样骨骼矩阵再对顶点做矩阵变换。这种方案其实离原骨骼动画更近动画资源能复用还能做部分融合但实际落地时坑更多。矩阵纹理需要的通道数多、精度要求高、实例之间动画混合困难而且顶点数量较多时采样次数反而增加。顶点动画纹理直接烘焙顶点本身优点非常直观顶点着色器不用做矩阵乘直接从贴图里取位置和法线。对独立游戏项目来说烘焙流程可控Shader逻辑简单出问题的排查路径短。所以我的项目里实际落地的就是顶点动画纹理下面所有内容也都围绕这个路线展开。2.2 数据结构位置、法线别混在一张图里我建议把位置偏移和法线分别烘焙成两张纹理或者至少用两套通道。位置值一般需要做范围归一化法线则适合用归一化到[-1, 1]的数值。两个值的语义不同放到一张纹理的RGBA里容易遇到精度和压缩问题。位置纹理我习惯用半浮点或全浮点格式里面存顶点相对初始位置的偏移。法线纹理一般用RGBA8或半浮点存世界空间或模型空间的法线。虽然法线用RGBA8的x、y、z通道再乘2减1就够但要注意半浮点纹理在移动端和有半浮点支持的PC上是不同的实际项目先确认目标平台的支持程度。数据纹理格式存储内容采样时机常见问题位置偏移半浮点/全浮点顶点相对初始位置的XYZ偏移顶点着色器压缩精度不够会抖动法线方向RGBA8/半浮点归一化法线XYZ顶点或片元着色器sRGB开启后颜色被二次校正动画时间显式参数当前动画播放进度每一帧更新帧率和烘焙采样率不一致2.3 纹理布局和顶点索引顶点动画纹理的横向是动画时间纵向是顶点索引。比如有2000个顶点100帧动画一张256x8的纹理就能放下。这里的行数至少要能容纳顶点数量而列数一般对应当前帧索引。采样时横向U坐标代表“当前是动画的第几帧”纵向V坐标代表“这个顶点是模型里的第几个顶点”。为了在纹理采样时不串行需要在V坐标上做半像素偏移。这里的半像素偏移不是调一次就能一劳永逸只要你换过纹理分辨率或改过行数就要重新检查。我在项目里专门写了一个函数做UV计算避免每次手动改Shader。3. 从角色模型到动画数据制作实操步骤3.1 在DCC工具里准备角色和动画我用的流程是先在Blender里做好角色模型和骨骼绑定再制作需要的动作。这个环节不一定要做得非常精致但有两个点要注意第一角色模型最后的顶点索引要稳定不要烘焙顶点动画纹理之后又改拓扑第二动作制作时尽量保持动画循环首尾一致像待机、走路这类循环动作不然后续渲染循环会有跳变。如果你已经有现成的骨骼动画资源是从引擎导出动画再转顶点动画纹理那也可以。流程是先让引擎按逐帧采样顶点位置再离线转成贴图。重点是一样的顶点顺序不能乱动画帧率要明确。3.2 烘焙顶点动画的脚本思路我没有用非常复杂的商业插件自己写了Python脚本完成数据导出。脚本做的事情不复杂逐帧遍历角色模型的所有顶点计算出相对初始位置的偏移同时记录该帧的法线方向。把这些数据写到一个临时文件里然后再用后续脚本生成纹理。这一步的坑主要在两个地方一是顶点顺序如果你烘焙时用的模型、运行时加载的模型不是同一套顶点顺序动画就会扭曲到完全看不出原来的动作二是帧率烘焙时的采样帧率要和运行时动画时间换算保持一致比如烘焙时用的30fps运行时却按60fps的时间轴推进动画播放速度就会快一倍。3.3 生成动画纹理分辨率、归一化和文件大小生成纹理时我先计算整个动画过程中所有顶点位置偏移的包围盒找到最大值把偏移缩放到[0, 1]区间。运行时在Shader里再乘回范围值。这样做的目的是让纹理通道能被充分利用避免因为某个顶点动得特别大导致其他小幅度动画细节全被精度吃掉。动画纹理的文件大小要考虑。一个2000顶点、100帧的角色动画如果什么都用32位浮点纹理资源包会变大不少。如果只是位置偏移用半浮点通常够了如果是待机这种小幅动作甚至可以考虑RGBA8配合范围压缩。独立游戏资源包越小加载越稳。把动画数量控制住比反复压一张纹理更有效。3.4 加载到引擎纹理导入设置和参数纹理导入到引擎时我建议关闭线性过滤或者使用点采样不然相邻顶点会在纹理过滤时发生不期望的插值。关掉Mipmap防止LOD层级不同导致采样错乱。Wrap Mode设置成Clamp防止时间超出纹理范围时出现边缘重复尤其是最后一帧和第一帧循环时Wrap会造成额外跳动。如果你用的是Unity纹理导入的sRGB要关掉因为顶点位置偏移不是颜色数据把它当成sRGB会在Shader里被额外做一次gamma校正位置会偏。这一点非常隐蔽。Unreal里也要把对应的压缩设置关掉用无损或半浮点格式。注意烘焙数据时Shader里读取的纹理格式、导入时的Texture Format、平台压缩策略三者必须一致否则你看到的就是顶点乱飞、动画闪烁和光照奇怪。4. Shader怎么写两种采样方式的取舍4.1 顶点着色器里采样动画纹理顶点动画纹理的Shader核心是把原本的骨骼矩阵变换换成动画纹理采样。在顶点着色器里根据顶点ID或者顶点索引计算行坐标再根据当前时间计算列坐标采样出位置偏移和法线最后加上模型本身的变换。这里给出一个简化版的HLSL思路float2 GetAnimUV(float vertexIndex, float animTime, float frameCount) { // vertexIndex 是从 0 到顶点数-1 的纵向行坐标 float v (vertexIndex 0.5) / _VertexCount; float u (animTime * (frameCount - 1) 0.5) / frameCount; return float2(u, v); } float4 offset tex2Dlod(_AnimPosTex, float4(GetAnimUV(vertexID, _AnimTime, _FrameCount), 0, 0)); float3 animatedPos vertex.vertex.xyz offset.xyz * _PosRange; float4 normalData tex2Dlod(_AnimNormalTex, float4(GetAnimUV(vertexID, _AnimTime, _FrameCount), 0, 0)); float3 animatedNormal normalData.xyz * 2.0 - 1.0;这个代码不是完整可跑版本它只展示核心逻辑。真正的工程里vertexID怎么拿到取决于你的引擎接口。Unity里可以用SV_VertexID也可以提前把顶点索引烘焙到UV2通道里。用SV_VertexID的好处是省一套顶点属性但在某些平台和合批场景下要额外处理。4.2 片元着色器里采样法线什么时候有必要如果模型面数很低或者动画里存在明显的起伏和褶皱单纯靠顶点插值出来的法线可能不够用。此时可以在片元着色器里再采样一次法线纹理得到逐像素法线。这个做法会带来额外纹理采样开销但能明显改善低模角色的光照表现。我一般会让项目里两种Shader并存一种是在顶点阶段采样一次用于小怪、杂兵另一种是在片元阶段采样法线用于主角和重要单位。不要在第一次做顶点动画纹理时就追求逐像素法线先把动画跑通再根据角色重要性决定加不加片元采样。4.3 采样精度和插值的坑顶点动画纹理最隐蔽的问题是采样插值。如果纹理是双线性过滤纹理在U方向插值可以理解为相邻两帧之间的自动补间这有时候是好事。但V坐标方向的插值会把当前顶点的位置和相邻顶点的位置混合模型表面就会像被糊了一层噪声。所以V方向一定要关掉过滤U方向可以保留双线性过滤用于动画平滑。大多数引擎不能单独控制纹理U和V的过滤模式因此干脆全用点采样然后自己在Shader里做时间方向的平滑或者手动取相邻帧做lerp。手动时间方向平滑的做法是计算出当前帧的整数部分和小数部分采样当前帧和下一帧然后按小数部分做lerp。这样能解决很多纹理过滤问题代价是采样次数翻倍。如果角色多这个代价不一定划算。我通常是先看看效果能不能接受再决定要不要做。5. 大量角色同屏实例化与动画偏移5.1 Instancing为什么是GPU动画的最强搭配GPU动画只解决“动画更新开销”真正让它发挥威力的场景是配合GPU实例化。因为角色的骨骼矩阵不再需要单独上传CPU向GPU提交的每一帧数据就变成实例的模型矩阵、动画时间、动画ID。这些数据的量级和角色的骨骼矩阵相比小得多。在Unity里可以用Graphics.DrawMeshInstanced或者DrawMeshInstancedIndirect。DrawMeshInstanced适合实例数量在几百到几千的场合CPU端每帧整理数组一次提交。DrawMeshInstancedIndirect适合数量更多的情况可以把实例数据放到ComputeBuffer里由GPU侧控制绘制范围。5.2 每个实例传什么参数我的做法是给每个实例填充这么几个参数模型变换矩阵、动画时间偏移、动画ID、缩放。动画时间偏移在RTS和模拟经营里特别有用。比如100个角色在等待如果它们从同一时刻开始播放待机动画视觉上会非常整齐反而显得假。给每个实例一个随机的初始时间偏移可以让群体动画看起来更自然。如果动画分好几段比如跑步、攻击、死亡就需要一个动画ID字段。同样的Shader可以根据ID在动画列表中查找对应的纹理。这里要保证所有动画的顶点烘焙都用同一套顶点顺序不然切换ID后角色会直接变形。UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float, _AnimTime) UNITY_DEFINE_INSTANCED_PROP(int, _AnimID) UNITY_INSTANCING_BUFFER_END(Props)5.3 阴影、深度和透明排序用了GPU动画之后最容易漏掉的是阴影Pass。如果阴影Pass只按静态蒙皮网格计算没有采样动画纹理角色就会在地面上留下完全不匹配的影子。灯光材质、阴影深度、补光用途的额外Pass都要复用同一个动画采样逻辑。透明排序方面如果角色用了半透明材质GPU实例化要小心RenderQueue的设置和混合模式。顶点动画本身不会造成排序问题但半透明角色穿插在场景里时排序错误会很明显。这种情况要单独处理不要把半透明实例和无透明实例混在一个批次里。6. 性能怎么对比、怎么判断边界6.1 不要只用FPS判断我见过很多朋友直接拿测试场景的FPS判断GPU动画值不值得做。这个结论有一个问题如果GPU本身很弱瓶颈可能被压到像素填充或几何处理上和动画更新方式关系不大。我更建议先看Profiler里的CPU耗时单独看Animation Update、SkinnedMesh Update、Renderer.Update这几项。骨骼动画的CPU耗时通常集中在SkinnedMesh和动画系统更新上GPU动画后这些项应该明显降低。还要看渲染线程和Draw Call。GPU动画配合实例化后角色相关的Draw Call应该集中到很少几个批次。如果数量很多但Draw Call没降先检查有没有被合批条件挡住。6.2 一个可以自己跑的对比测试可以在场景里放一批角色分成两组对比一组用传统SkinnedMeshRenderer另一组用自写的GPU动画渲染。从10个角色开始逐步增加到50、100、200、500观察每个档位的CPU帧时间、Draw Call和显存占用。如果两组在10个角色时差别不大这很常见。因为单角色骨骼动画的成本并不高。到了100以上差距应该会拉开。如果200个角色时GPU动画确实能让CPU帧时间下降但帧率没有提升说明你的瓶颈可能在GPU这时候要继续优化的是模型面数、阴影和Overdraw而不是继续压CPU动画。6.3 资源占用边界怎么估算顶点动画纹理的资源占用主要看三个变量顶点数量、动画帧数、动画数量。单段动画的纹理大小大概等于“顶点数乘帧数乘一个系数”。顶点多、动画多资源增长很快。我一般会把单个角色的动画总帧数控制在一个范围内比如移动循环和待机循环优先保留大招动画宁可做短一点也不让它占太多纹理空间。影响因素增加后的结果优化建议顶点数量纹理行数增加单帧数据变大控制模型面数低模优先动画帧数纹理列数增加总数据量线性变大循环动画控制在60-120帧动画数量纹理张数增加只保留必要的动作合并同类循环纹理格式半浮点和全浮点体积差异明显先半浮点量化看效果能否接受注意低配置设备能跑通单个角色顶点动画纹理不代表能跑通几百个实例的顶点动画纹理。实例数量翻倍GPU端动画采样次数也翻倍这部分的GPU开销不能假装不存在。7. 踩过的坑和排查顺序7.1 动画错乱先检查顶点顺序和SV_VertexID如果你发现角色动画跑起来完全不像正常的动作甚至像是顶点在随机飞第一反应先别改Shader参数。优先检查两件事烘焙时的顶点顺序以及运行时VertexID是否对得上。如果烘焙脚本和运行时模型来自不同导出流程顶点顺序基本是必错。解决方法是把顶点索引写进模型的UV通道Shader从UV里读索引而不是依赖SV_VertexID。7.2 动画闪烁先看纹理过滤和sRGB动画播放中有闪烁、噪点、某些顶点跳来跳去优先检查纹理导入设置。关了过滤、关了Mipmap、关了sRGB大部分闪烁问题会消失。如果还没有去看纹理格式是否被压缩成块压缩格式BC压缩对浮点数据的失真很大。移动平台上一定要确认纹理是否被当成颜色纹理做压缩。7.3 光照不对先看法线纹理如果动画跑动正常但光照硬邦邦的或者模型像被粗糙的凹凸条痕覆盖就要看法线数据。顶点动画只改了位置没有改法线光照自然不对。要么烘焙时同时记录法线到纹理要么用较密网格让顶点法线插值足够用。不要指望靠Shader里重建法线解决所有问题先把数据烘焙对。7.4 换装、LOD和动画融合的边界顶点动画纹理在这三个地方都比较吃力。换装如果衣服会动顶点动画纹理必须为每种换装组合烘焙一份完整动画贴图组合一多资源爆炸。LOD远距离的低模要以同样的采样规则读取动画纹理如果低模顶点数变少顶点索引对应不上需要重新烘焙对应的纹理行。动画融合顶点动画纹理只保存了完整动画帧做不了骨骼权重融合你只能在烘焙前把需要的过渡动画单独烘焙出来。这些场景要么提前规划要么干脆保留骨骼动画做主要角色和需要换装的单位GPU动画只负责大批量的平民、杂兵和植物类角色。独立的决策比强行一套方案覆盖所有角色靠谱得多。8. 什么时候值得用GPU动画以及怎么长期维护8.1 我的判断标准我的判断标准比较简单。如果同屏角色长期超过30个并且它们的动画以循环动作和简单技能为主GPU动画就值得投入。如果角色数量通常只有几个到十几个那传统骨骼动画的维护成本和兼容性都更好。独立游戏做功能要讲性价比不要把技术路线当成全项目的执念。如果已经确定要上GPU动画还有一个时间成本要算进去。第一次搭建数据烘焙和Shader链路需要完整调通这段时间比想象中长。不要只算“写代码”的时间还要加上角色导出规范、纹理格式验证、实例化渲染联调。项目时间紧张时先用最简单的方式把角色原型跑起来再逐步替换成GPU动画会更稳妥。8.2 优先稳住一套数据链路无论你选顶点动画纹理还是其他GPU动画方案最核心的是把数据链路稳住。角色模型导出、顶点动画烘焙、纹理生成、引擎导入设置、Shader采样、实例化提交这六个环节只要有一个不一致表现在画面上就是整个堆叠的动画问题。优先做一个小工具把烘焙、纹理生成、Shader参数检查捆在一起保证每个新角色只需填写很少几个字段就能上线。我还会在测试场景里放一组“标准参考角色”专门用来验证新角色的动画数据是否正常。这类参考角色一般有固定的顶点数、帧数、动作列表。新角色接入后先和标准参考角色对比能快速定位是烘焙问题还是Shader问题。8.3 后续值得看的优化方向后面如果想把GPU动画做得更细可以研究几个方向动画纹理的压缩和量化、双层级动画方案远处实例化顶点动画近处保持高质量骨骼动画、按角色重要性分级烘焙。还有一个很实用的思路把角色动画和移动逻辑分离开减少角色控制逻辑对动画系统的依赖。动画渲染层只负责怎么画移动逻辑只管位置旋转这样GPU动画项目会更好维护。这些经验是我在一个规模不大、角色数量却不小的项目里反复调出来的。如果你也在做独立游戏正被同屏角色动画卡住先用第7章的排查顺序过一遍再把单任务跑稳再考虑批量和实例化。GPU动画不是银弹但在正确的场景里它能让你把CPU省下来做更重要的游戏逻辑。
返回列表