UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - usparkle
这篇文章主要和大家聊一聊带波浪的地面的实现，以及场景特效的实现方法。如下图所示，地板随时间以波浪状变化，角色在地板上走的时候会随着它的波浪高低起伏。 视觉实现 关于波浪效果的实现，作者最开始的时候走了些弯路。一开始，我想的是试试用直接修改地板Mesh的方式，达到视觉和物理上的同步实现。事实上这被证明是非常不科学的，因为修改Mesh会严重影响性能。在涉及物理的情况下，我测试的结果是游戏帧率调到了个位数。所以最终选择了利用Shader实现视觉效果+利用脚本伪造行走物理效果的办法。 首先是地板的模型，这里使用了一个Plane： 波浪函数的实现，这里是函数实现，用的是两个方向的波进行叠加，波纹随时间变化。即构造一个全局函数，因为c#代码和Shader代码里都要使用，所以这是一个以World-Space的坐标x,z，以及游戏时间Time.time为变量的，以波浪高度y为结果的函数。使用的波纹函数为三角函数： 两路波形线性叠加，具体实现如下： [SerializeField] float height1 = 1f; [SerializeField] float frequency1 = 1f; [SerializeField] float wavelength1 = 1f; [SerializeField] Vector3 wave1Direction; [SerializeField] float height2 = 1f; [SerializeField] float frequency2 = 1f; [SerializeField] float wavelength2 = 1f; [SerializeField] Vector3 wave2Direction; [SerializeField][Range(0,10f)] float overallHeight = 1f; [SerializeField][Range(0,10f)] float overallFrequency = 1f; [SerializeField][Range(0,10f)] float overallWaveLength = 1f; [SerializeField] float offset; public float GetHeight( float x , float z ) { float x1 = x * wave1Direction.normalized.x + z * wave1Direction.normalized.z; float y1 = Mathf.Sin (x1 / (wavelength1 * overallWaveLength) + Time.time * frequency1 * overallFrequency) * height1 * overallHeight; float x2 = x * wave2Direction.normalized.x + z * wave2Direction.normalized.z; float y2 = Mathf.Sin (x2 / (wavelength2 * overallWaveLength) + Time.time * frequency2 * overallFrequency) * height2 * overallHeight; return y1 + y2 + offset; } 当然这是在C#里的实现，其实和在Shader里的实现类似。不过注意，需要把函数的参数传给Material。同时，需要每帧对时间参数进行同步(C#代码）： public void SetMaterial( Material material ) // called at start { material.SetFloat ("_height1", height1 * overallHeight); material.SetFloat ("_frequency1", frequency1 * overallFrequency); material.SetFloat ("_waveLength1", wavelength1* overallWaveLength); material.SetFloat ("_height2", height2 * overallHeight); material.SetFloat ("_frequency2", frequency2 * overallFrequency); material.SetFloat ("_waveLength2", wavelength2* overallWaveLength); material.SetVector ("_wave1Direction", wave1Direction.normalized); material.SetVector ("_wave2Direction", wave2Direction.normalized); material.SetFloat ("_waveOffset", offset); } public void Update Material(Material material ) // called at update { material.SetFloat ("_Timer", Time.time); } 在Shader里，需要在Vert函数里，对顶点的位置进行重新计算： v2f vert(appdata v ) { v2f o; o.uv = v.uv; o.worldPos = mul(unity_ObjectToWorld , v.vertex); o.worldPos.y = GetWaveHeight( o.worldPos.x , o.worldPos.z , _Timer); v.vertex.y = mul(unity_WorldToObject , o.worldPos).y; o.vertex = UnityObjectToClipPos( v.vertex ); } 实现的效果如下： 至此，视觉部分已经完成。 这里把错误示范列出来，利用Mesh实现波浪效果的脚本： public Mesh m_mesh; void Update () { { List<Vector3> verticles = new List<Vector3> (m_mesh.vertices); for (int i = 0; i < verticles.Count; ++i) { var vect = verticles [i]; vect.y = GetWaveHeight( vecticles[i].x , verticles[i].z ); verticles [i] = vect; } m_mesh.SetVertices (verticles); m_mesh.RecalculateBounds (); } } 逻辑实现 逻辑部分的实现也是绕了不少弯路。 一开始想的是直接更改Mesh来实现，但是消耗太大，故弃之。 接下来的思路还是利用物理系统，用一个Cube模拟玩家脚下的地面，每帧更新它的位置和角度信息。理论上来说，这种实现方法是可以模拟玩家和地表的物理碰撞。但只是理论，实际上这种实现方法会出现比较严重的抖动情况。（角色的控制使用的是自带的Character Controller） 最后的实现方法是，在角色控制的脚本里，直接对角色的位置进行修改。由于波纹函数是平滑的，所以实现的效果也可以做到比较平滑。同时，根据场地形的斜度对速度进行一定的调整，达到上坡下坡的效果。 float climbRate = Mathf.Clamp( WaveController.Instance.GetGradient ( Position.x , Position.z , m_MoveDir) * 10f + 1f , 0.6f , 1.5f ) ; speed *= climbRate; 斜率的计算直接用数值法求导（用导数法太麻烦了）: public float GetGradient( float x , float z , Vector3 velocity ) { // if the velocity is too small if (velocity.magnitude < Mathf.Epsilon) return 0; float thisY = GetHeight (x, z); Vector3 delta = velocity * Time.deltaTime; float otherY = GetHeight (x + delta.x, z + delta.z); // the gradient is delta Y / delta V return - (otherY - thisY) / delta.magnitude; } 弄好之后，就可以愉快地用角色爬坡了~ 后期特效实现 后期的特效实现的是一种反向Bloom的效果，下图是该效果夸张处理后的实现： 物体的黑色部分被加强，相当于一个反向的Bloom。 选择Standard Asset里的Optimized Bloom作为基础，进行修改。源代码在Asset Store上可以免费下载： https://assetstore.unity.com/packages/essentials/legacy-image-effects-83913 首先分析Bloom的代码，查看BloomOptimized和在MobileBloom.Shader里，可以看到Bloom的流程是： Downsample（提取颜色高亮的部分）->Blur（进行横竖方向的模糊）->Bloom(把高光颜色和原画面进行叠加) 根据这个流程，如果我们需要把Bloom效果反向，在Bloom部分把颜色进行剔除，同时需要在DownSample部分进行修改，进行曲线上的调整。 具体的做法如下： fixed4 fragDownsample ( v2f_tap i ) : SV_Target { fixed4 color = tex2D (_MainTex, i.uv20); color += tex2D (_MainTex, i.uv21); color += tex2D (_MainTex, i.uv22); color += tex2D (_MainTex, i.uv23); //return max(color/4 - THRESHHOLD, 0) * ONE_MINUS_THRESHHOLD_TIMES_INTENSITY; // this curve is used for no reason, I just feel it looks good return ( color / 4 ) * THRESHHOLD + 1 - THRESHHOLD; } fixed4 fragBloom ( v2f_simple i ) : SV_Target { #if UNITY_UV_STARTS_AT_TOP fixed4 color = tex2D(_MainTex, i.uv2); // return color + tex2D(_Bloom, i.uv); // combine the color by mutiply return color * max( 1 - ( 1 - tex2D(_Bloom, i.uv)) * INTENSITY , 0 ); #else fixed4 color = tex2D(_MainTex, i.uv); // return color + tex2D(_Bloom, i.uv); return color * max( 1 - ( 1 - tex2D(_Bloom, i.uv)) * INTENSITY , 0 ); #endif } 最后的成果展示： 这是侑虎科技第399篇文章，感谢作者邓佳迪供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。QQ群：793972859 作者个人主页：https://zhuanlan.zhihu.com/p/34806560，同时，作者也是U Sparkle活动参与者哦，UWA欢迎更多开发朋友加入U Sparkle开发者计划，这个舞台有你更精彩！
此文将Unity移动端的大量美术阴影作了归纳总结，如Cubemap的动态软阴影、地面云阴影、角色脚下的阴影面片等阴影效果，无论是对项目的制作开发还是平日的学习积累，都是极具参考价值的宝贵素材。 壹 · 基于Cubemap的动态软阴影 ARM公司曾利用Unity开发过两款技术Demo（Ice Cave 和 Chess Room），里面充分发挥了Cubemap的强大威力—既用来做地面反射、冰块折射，还用来做动态软阴影，利用简单的技术做出了高品质的画面。下面是Ice Cave的效果： 其中反射、折射部分参考：Reflections Based on Local Cubemaps in Unity、ARM Guide for Unity Developers，下面主要介绍下软阴影部分原理。 以此国际象棋屋为例，屋子中间放置一个Reflect probe来拍摄周围环境，只用了Cubemap的RGB通道，而周围环境的Alpha其实也代表了光是穿透了窗户还是被墙壁遮挡，那就可以利用Cubemap剩余的Alpha通道就可以来存储光和周围环境的遮挡情况，Alpha通道图如下： 生成Cubemap细节可以参考AssetStore中的源码。 利用生成的Cubemap渲染阴影主要分为两步，一是向量L（vertex-to-light）转换为Lp（校准过的vertex-to-light，用来采样Cubemap用），二是软阴影处理。 1. L到Lp向量校准： 输入参数： _EnviCubeMapPos >> Cubemap 中心坐标 _BBoxMax >> 包围盒最大坐标，生成Cubemap时自动生成 _BBoxMin >> 包围盒最小坐标，生成Cubemap时自动生成 V： >> 顶点坐标 L： >> vertex-to-light向量，已normalized 输出参数： Lp >> 校准后的vertex-to-light向量，作为UV去采样Cubemap 校准过程： // Working in World Coordinate System. vec3 intersectMaxPointPlanes = (_BBoxMax - V) / L; vec3 intersectMinPointPlanes = (_BBoxMin - V) / L; // Looking only for intersections in the forward direction of the ray. vec3 largestRayParams = max(intersectMaxPointPlanes, intersectMinPointPlanes); // Smallest value of the ray parameters gives us the intersection. float dist = min(min(largestRayParams.x, largestRayParams.y), largestRayParams.z); // Find the position of the intersection point. vec3 intersectPositionWS = V + L * dist; // Get the local corrected vector. Lp = intersectPositionWS - _EnviCubeMapPos; 先利用线和包围盒求交点，从包围盒位置到交点的向量就是Lp，然后利用Lp去采样Cubemap用于着色。 float shadow = texCUBE(cubemap, Lp).a; 另外背面要特殊处理下，防止阴影穿透问题。 if (dot(L,N) < 0) shadow = 0.0; shadow *= max(dot(L, N), 0.0); 2. 软阴影： 阴影平滑的过程比较有趣，首先Cubemap过滤方式选择tri-linear filtering，然后计算vertex-to-intersection-point（顶点到交点）向量的长度，然后乘以外部传入系数： float texLod = length(IntersectPositionWS - V); texLod *= distanceCoefficient; 为了平滑阴影，我们用texCUBElod 去采样Cubemap，其中UV的XYZ来自Lp，W来自vertex-to-intersection-point（顶点到交点）的距离。 Lp.w = texLod; shadow = texCUBElod(cubemap, Lp).a; 下图也可以看到离窗户越远处的阴影越模糊。 这种阴影比较适合室内环境、点光源位置不变、内部有移动物体的情况。 贰 · 地面云阴影 对于地面上云阴影，用实时灯光照射出阴影显然是不划算，可以直接在地面Shader中混合一个运动的云图就能达到类似效果。 我用Shaderforge拖出了一个简单的版本： 另外这种方法也可以用来做地面风雪效果。 叁 · 植物摇曳阴影 对于树、草、旗子这类位置不变但有摇曳动画的物体，可以预先把阴影烘焙到贴图中，然后把阴影图作为单独贴图、或地面贴图Alpha通道传送到地面shader中，然后只需要添加阴影晃动的特性就可以随植物晃动而晃动，伴随有一种真实阴影的感觉。另外注意阴影的方向、和植物晃动的方向同步等细节。 具体细节可以参考：手机游戏中大量植物图像的伪阴影渲染 肆 · 结合Projector和Rendertexture的实时阴影 创建一个跟随主相机的阴影相机，改为正交投影，设置单独的shadow Layer，将需要投射阴影物体设置到shadow layer，为此阴影相机设置渲染目标到一个渲染纹理RTT_Shadow。另外创建一个Projector，为它设置一个材质Mat_Proj，并将RTT_Shadow传到Mat_Proj的shader中进行着色，另外为防止投影相机边缘的刺刺的长线，要设置一个阴影衰减纹理，如果需要软阴影则需要另外Blur。 这是最近几年手游应用比较广泛的方法，网上有很多相关文章，比如：结合Projector和Rendertexture实现实时阴影、ProjectorShadow（手游上的实时阴影方案） 另外AssetStore也有不少类似插件：Fast Shadow Projector 伍 · 角色脚下阴影面片 对于游戏中的NPC、杂兵、野怪这些非关键性角色可以直接设置一个阴影面片来模拟阴影，当然如果地面起伏比较大可能会有穿插问题。 陆 · Light Probe 具体细节参考Unity手册不赘述了：Light Probes 柒 · Shadow Maps 1.Standard Shadow Mapping 基本思想是在光源位置放置一个相机(Light space Camera)，画一遍深度得到深度图，在渲染场景时将pixel坐标转换Light Space计算深度，然后比较它深度和深度图中的深度，如果比深度图中深度大就意味着在阴影中，否则在被照亮。 阴影的锯齿有两类：透视导致的锯齿(Perspective alias)和投影导致的锯齿（Project alias）。 2.PCF 投影导致的锯齿是因为灯光投射方向和物体表面夹角过小时多pixel对应阴影图的一个texel，这可以通过提高阴影图的大小来解决，也可以通过Percentage Closer Filtering来柔化边缘。PCF就是在绘制时，除了绘制当前点还会对周围像素进行多次采样、混合来柔化锯齿，常用PCF有：使用随机采样实现soft shadow、泊松采样等。 3.PSM 透视导致的锯齿是因为透视的近大远小所导致的，于是就有了Perspective Shadow Map，它将整个Shadow Map的计算过程转到归一化设备空间（NDC）来计算，这就消除了近大远小的问题。下图是Standard Shadow Map和经过Perspective Shadow Map优化过的阴影，阴影明显更细致。 可是PSM本身有很大局限性，比如影子质量比较依赖视角方向、近处阴影与远处阴影Z分布过大。 4.LISPSM 在PSM的基础上又有了新的阴影技术Light Space Perspective Shadow Maps，它是在和灯光方向垂直的方向构建View Frustrum，然后将灯光、场景都转到这个View Frustrum的Perspective space，然后再计算Shadow Map，这样无论是点光、聚光、平行光就都转为平行光。 左图是Uniform（近处精度不足），中间是LISPSM（近处、远处都不错），右面是PSM（远处精度不足）。LISPSM具体细节参考： https://www.cg.tuwien.ac.at/research/vr/lispsm/shadows_egsr2004_revised.pdf 5.VSM（方差阴影） 在使用PCF时一般不能提前对Shadow Map进行模糊处理，因为这会导致PCF计算不准，而Variance Shadow Maps则没有这样的限制。VSM存储的Shadow Map不仅包括深度，还有深度的平方，这时可以对Shadow Map做过滤，然后利用切比雪夫不等式计算出大于当前深度的概率上限，也就是阴影区的概率。切比雪夫不等式： 左图是Standard Shadow Map，右图是Variance Shadow Map，具体细节参考：Variance Shadow Mapping、VSM的demos、Matt's Variance Shadow Maps 6.CSM / PSSM 这是两种分别研究发表但是原理几乎一样的阴影技术，Unity用的就是CSM，而其中PSSM是几个中国人（Zhang F, Sun H Q, Xu L L, et al）提出的。它们的原理如下： a) 对摄像机视锥体内沿着Z由近到远切阴影图分为多张，而切分是两种切分规则的混合，一种是均匀切分，一种是指数切分，两者按照一定比率混合起来。 b) 对每一块分别计算一个光源投影空间内平移、缩放的矩阵cropMatrix，它可以将切分的多块移动、缩放到光源的视椎中，这个矩阵和正交投影矩阵非常像。 // Build a matrix for cropping light's projection // Given vectors are in light's clip space Matrix Light::CalculateCropMatrix(Frustum splitFrustum) { Matrix lightViewProjMatrix = viewMatrix * projMatrix; // Find boundaries in light's clip space BoundingBox cropBB = CreateAABB(splitFrustum.AABB, lightViewProjMatrix); // Use default near-plane value cropBB.min.z = 0.0f; // Create the crop matrix float scaleX, scaleY, scaleZ; float offsetX, offsetY, offsetZ; scaleX = 2.0f / (cropBB.max.x - cropBB.min.x); scaleY = 2.0f / (cropBB.max.y - cropBB.min.y); offsetX = -0.5f * (cropBB.max.x + cropBB.min.x) * scaleX; offsetY = -0.5f * (cropBB.max.y + cropBB.min.y) * scaleY; scaleZ = 1.0f / (cropBB.max.z - cropBB.min.z); offsetZ = -cropBB.min.z * scaleZ; return Matrix( scaleX, 0.0f, 0.0f, 0.0f, 0.0f, scaleY, 0.0f, 0.0f, 0.0f, 0.0f, scaleZ, 0.0f, offsetX, offsetY, offsetZ, 1.0f); } c) 针对切分的每一块渲染阴影图，一般阴影图大小一样的，比如都是1024*1024，而近处包含的场景范围比远处小，所以近处阴影图的精度会更高。 d) 渲染场景阴影 关于CSM和PSSM具体细节参考：Cascaded Shadow Maps、Parallel-split shadow maps for large-scale virtual environments、PSSM from GPU Gems 3 另外Shadow Maps还有很多其他变种：已知Shadow Maps名称汇总 这是侑虎科技第239篇原创文章，感谢作者冯委供稿，欢迎转发分享，未经作者授权请勿转载。当然，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者知乎：https://zhuanlan.zhihu.com/c_90452095。同时，作者也是U Sparkle活动参与者哦，UWA欢迎更多开发朋友加入 U Sparkle开发者计划，这个舞台有你更精彩！
笔者在心动网络负责《仙境传说RO：守护永恒的爱》的性能优化，今天他将为大家分享Lua性能优化方面的一些经验，主要为拿到原始数据后，如何处理过滤数据、信息的经验，从而更快更准确地定位问题。 导语：大家好，我是舒航，现任职于心动网络，主要负责《仙境传说RO：守护永恒的爱》（下简称"RO"）的优化工作。今天主要想和大家分享一下，我这段时间在Lua性能优化方面的一些经验。 现在市面上大部分Unity游戏都能支持热更，主要热更Lua和一些资源。而Lua主要实现一些UI界面之类的非核心逻辑，这样虽然Lua这部分代码非常灵活，增加功能、修复BUG都非常方便。但是由于Lua实现的大部分都是UI，那么Lua热更出去的功能最多就是一些新UI界面罢了，或者用资源配合配置表来扩充下游戏既有内容。如果想做个特殊的玩法势必受到掣肘，一般最后都会发现导出的接口不全，非得打整包才行。为了使得RO的玩法更灵活，热更的游戏内容更丰富，其实就是为了满足策划们的各种需求啦，RO战斗逻辑的主体都是在Lua中完成。所以RO相对于其他的游戏，对Lua代码的性能要求会更高一些。 在我对Lua代码进行性能优化的时候，主要分为两部分： 1.内存优化 常驻内存优化 内存分配优化 内存泄露优化 2.CPU优化 这篇文章主要是和大家分享第一点，在内存优化上的一些经验和方法。在我进行Lua优化之前，我们已经请过UWA团队进行过深度的优化了，而且收效很好，在此给UWA团队赞一个。那么，面对一个已经进行过一次彻底优化的项目，要进行更深入的优化之前首先要问自己一个问题： Q： 我为什么能比前人优化得更彻底，做到前人没有做到得事情呢？ 总的来说就是两点：1）工具更先进，2）项目更熟悉。 所以在这样的策略下，我主要是针对我们的项目编写了两个工具：LuaMemoryMonitor和LuaProfiler。一个用于优化内存泄露和内存分布，一个用于优化内存分配。有了更好的工具之后，再针对性地优化自己项目的代码，无论是效率还是结果都非常好。LuaMemoryMonitor能在十几分钟内就定位到泄露的代码，用这个工具我们一下午就查清了战斗中的内存泄露，并且能清晰地列出Lua的内存分布。而LuaProfiler帮助我们把Lua内存分配速度降低到了原来的40-50%。 LuaMemoryMonitor LuaMemoryMonitor主要由两部分组成： C库Snapshot 和 UnityEditor ，下面我们分别说明。 一、C库的实现细节 C库的主要工作有： 快照_G 计算Lua对象内存大小 储存快照 1.快照_G Lua中可回收的对象递归关系如图： 遍历_G时按这个结构关系进行遍历，只需要编写5个函数traverse_object、traverse_table、traverse_function、traverse_userdata、traverse_thread，然后从traverse_table(_G)开始递归即可。在统计内存大小时，即要统计对象本身占用的内存，也要递归地遍历它所引用的所有对象占用的内存。其中string是Lua内部管理的，统计时要注意同一字符串的不同引用不能重复统计内存。 2.计算Lua对象内存大小 对Lua当前内存进行快照时，我主要是快照_G，如果有需要的话可以快照Registry。如果仅仅是查内存泄露，那么不统计_G内每一Entry的内存大小也可以。但是我为了分析内存分布，那么必须得统计每一Entry的内存大小。统计Lua对象的内存大小是没有现成的接口的，我这里给大家提供一种思路，也是Lua源码中统计对象内存大小的办法。例如一个Table占用的内存大小为： 3.储存快照 由于要对Lua内存进行分析，那么就不能把快照又存到Lua内，这样很容易干扰自己的数据采集。这也是把快照的代码写在C里的一个重要原因，如果是在Lua里写的话，就必须要小心翼翼地处理这些数据了。而写在C里就很好解决了，我在C里用一颗多节点树来储存每一个快照，多个快照组成一个链表。 二、Unity Editor 1.Editor特性 Snapshot库已经为编辑器准备好了所有数据了，现在只需要想一个好主意、好方法来利用这些数据。这里我做了一个特别的功能，可以很高效地利用这些数据。在UnityEditor里可以对每个快照进行逻辑操作。 求交集：即两个或多个快照中都存在的对象，即这些快照中的常驻内存。 求补集：A在B中的补集指在A中但不在B中的对象，即A相对于B的增量。 简单例子：在刚进入战斗时采样得到A快照，战斗一段时间后采样得到B快照，离开战斗场景回到主城，手动GC后采样得到C快照。求AB的补集得到一个新的快照D，D即是战斗期间新增的内存。求AC的补集得到快照E，E即是战斗期间新增并且离开战斗GC后没有释放掉的内存 。对D和E求交集，得到快照F，F即是战斗中新增但是回到主城后释放掉的内存。这其中E中的对象非常有可能就是泄露了的内存，而F中的对象是可以尝试更早地释放的内存。这时可以选中E快照，把E快照输出到Editor上，输出为一个树状结构，就像Unity自带的Profiler中一样，如果有泄露的话基本上就无所遁形了。而直接输出A快照的话，就能得到刚进入战斗时的内存分布。 三、常见泄漏：C#代理 我们项目中查到的比较多的泄露就是C#代理了，如果把Lua匿名函数注册给C#的代理，那么这个Lua匿名函数将不能正确地被LuaGC了，也就是泄露了。改进方法就是不把Lua匿名函数注册给C#代理，这样的话，每隔一段时间C#都会主动Dispose。 四、其他内存优化：常驻内存优化 常驻内存方面我们一直控制的很好，这次我们主要是优化了大量的table配置表，这个优化主要参考了UWA中的这篇文章【 Lua配置表存储优化方案—卢建】 五、LuaMemoryMonitor改进 现在LuaMemoryMonitor定位是一个快速定位泄露的工具，还需要提前知道疑似泄露的地方，然后有针对地采样。有一个改进方法是，在Lua中写一个定期检查内存疑似泄露的工具，然后在疑似泄露的地方用LuaMemoryMonitor进行快速定位。 LuaProfiler 解决了常驻内存和内存泄露的麻烦后，发现内存增长得很快，很短时间内就会到达我们项目设置的阈值，迎来一次GC。而刚GC完空余出来的内存又会迅速地被分配出去，内存长时间处于高位。频繁分配内存不仅降低了性能，还使手机更容易发热了。为了定位和优化内存的高分配，我模仿Unity的Profiler写了一个LuaProfiler，并做了相应的扩展。这个工具也由两部分组成：1）C库 , 2）UnityEditor。 一、 LuaProfiler的C库 LuaProfiler的C库主要完成了数据采集的工作。它在Lua虚拟机中注册了钩子函数，每次Lua Call 和 Return 的时候都会触发回调。在每次回调的时候，在C里维护了一颗限制层级的多节点树，由C#主动来取这颗树。每帧都取每帧都清空，即是逐帧统计。每隔一段时间取，但不清空，即是累计模式。 二、 LuaProfiler的UnityEditor Editor的主体是一组递归绘制的foldout控件，在Editor上显示出一个树状结构。LuaProfiler能实时地根据调用层级，然后通过各个按钮实现各种规则排序。这和UnityProfiler相同，想必大家不会陌生。另外LuaProfiler不仅提供了和UnityProfiler一样的逐帧模式，还提供了统计模式和累计模式。在累计模式下，会把每一帧的内存分配累加起来，以树状结构的方式展示出来。而统计模式则是把每一个函数的内存分配累加起来，以Top10的形式展现出来。统计模式不关心调用层级，只关心所有函数中哪些函数分配的内存最多。在这样一个工具的帮助下，RO的内存分配优化变得格外简单、高效。 三、 常见优化：string.gsub和string.gmatch 在我们项目中，用这两个string库函数完成了一个计算中文个数、长度的工具函数。但是容易被忽略的是，string.gsub 和 string.gmatch 会产生大量的子串，这些子串都会开辟一片内存，而我们根本用不上这些子串。我发现函数1在很多项目中都普遍存在，但是用函数2会更好一些。 四、 常见优化：Lua中String是不可变值 这一点也经常被大家忘记，哪怕是写Lua的老手。在以下代码中，因为Lua的string是不可变值，每次拼接都会产生一串新的字符串。第6行会产生"仙境"、"仙境传"、"仙境传说"一共3串字符串，但是我们只是需要第三串而已（“仙”字被Lua背部重用了）。这无形中就多开辟了一部分内存，我们可以对以下代码进行优化，从而避免浪费。这种疏忽经常出现在 I/O文件、聊天频道、处理配置等描述字段时发生。 五、 常见优化：内存池 如果想降低内存分配速度，使用内存池复用对象是必不可少的。在Lua内存池的使用过程中，最容易出现的问题是，忘了放回池子以及池子大小不合理。 总结 全文上下，用到的技术都不难，相信大家都能搞定。在这里主要是和大家分享一下拿到原始数据后，如何处理过滤数据、信息的经验，从而更快更准确地定位问题。如果大家有更好更精准的处理数据、过滤信息的方法请不吝赐教。 笔者联系方式 邮箱：inkiu0@gmail.com QQ： 286553528 UWA群内ID：ink_U0 文末，再次感谢舒航的分享，后续他将会给大家带来LuaProfiler的CPU优化部分，主要内容包括： 1）用多线程缓解DeepProfile数造成的卡顿 ； 2）Lua的CPU时间优化经验； 3）Lua的CPU时间到底消耗在哪一块，Lua是不是不堪大任呢？ 这是侑虎科技第236篇原创文章，感谢作者舒航供稿，欢迎转发分享，未经作者授权请勿转载。当然，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 同时，作者也是U Sparkle活动参与者哦，UWA欢迎更多开发朋友加入 U Sparkle开发者计划，这个舞台有你更精彩！
导语：大家都知道LuaJIT比原生Lua快，快在JIT这三个字上。但实际情况是，LuaJIT的行为十分复杂。尤其JIT并不是一个简单的把代码翻译成机器码的机制，背后有很多会影响性能的因素存在，下面笔者将带大家一一说明。 这是侑虎科技第231篇原创文章，感谢作者招文勇供稿，欢迎转发分享，未经作者授权请勿转载。当然，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者博客：http://www.cnblogs.com/zwywilliam。同时，作者也是U Sparkle活动参与者哦，UWA欢迎更多开发朋友加入 U Sparkle开发者计划，这个舞台有你更精彩！ 一、LuaJIT分为JIT模式和Interpreter模式，首先要弄清楚你使用的模式 同样的代码，在PC下可能以不足1ms的速度完成，而到了iOS却需要几十ms，是因为PC的CPU更好？是，但要知道顶级iOS设备的CPU单核性能已经是PC级，几十甚至百倍的差距显然不在这里。 这里要了解LuaJIT的两种运行模式：JIT、Interpreter JIT模式：这是LuaJIT高效所在，简单地说就是直接将代码编译成机器码级别执行，效率大大提升（事实上这个机制没有说的那么简单，下面会提到）。然而不幸的是这个模式在iOS下是无法开启的，因为iOS为了安全，从系统设计上禁止了用户进程自行申请有执行权限的内存空间，因此你没有办法在运行时编译出一段代码到内存然后执行，所以JIT模式在iOS以及其他有权限管制的平台（例如PS4，XBox）都不能使用。 Interpreter模式：那么没有JIT的时候怎么办呢？还有一个Interpreter模式。事实上这个模式跟原生Lua的原理是一样的，就是并不直接编译成机器码，而是编译成中间态的字节码（bytecode），然后每执行下一条字节码指令，都相当于switch到一个对应的function中执行，相比之下当然比JIT慢。但好处是这个模式不需要运行时生成可执行机器码（字节码是不需要申请可执行内存空间的），所以任何平台任何时候都能用，跟原生Lua一样。这个模式可以运行在任何LuaJIT已经支持的平台，而且你可以手动关闭JIT，强制运行在Interpreter模式下。 我们经常说的将Lua编译成bytecode可以防止破解，这个bytecode是Interpreter模式的bytecode，并不是JIT编译出的机器码（事实上还有一个在bytecode向机器码转换过程中的中间码SSA IR，有兴趣可以看LuaJIT官方WIKI），比较坑的是可供32位版本和64位版本执行的bytecode还不一样，这样才有了著名的2.0.x版本在iOS加密不能的坑。 二、JIT模式一定更快？不一定！ iOS不能用JIT，那么安卓下应该就可以飞起来用了吧？用脚本语言获得飞一般的性能，让我大红米也能对杠iPhone！然而，并不是安卓不能开启JIT，而是JIT的行为极其复杂，对平台高度依赖，导致它在以arm为主的安卓平台下，未必能发挥出在PC上的威力，要知道LuaJIT最初只是考虑PC平台的。 首先我们要知道，JIT到底怎么运作的。LuaJIT使用了一个很特殊的机制（也是其大坑），叫做trace compiler的方式，来将代码进行JIT编译的。什么意思呢？它不是简单的像C++编译器那样直接把整套代码翻译成机器码就完事了，因为这么做有三个问题： 编译时间长，这点比较好理解。 更关键的是，作为动态语言，难以优化。例如对于一个function foo(a)，这个a到底是什么类型，并不知道，对这个a的任何操作，都要检查类型，然后根据类型做相应处理，哪怕就是一个简单的a+b都必须这样（a和b完全有可能是两个表，实现的__add元方法），实际上跟Interpreter模式就没什么区别了，根本起不到高效运行的作用； 很多动态类型无法提前知道类型信息，也就很难做链接（知道某个function的地址、知道某个成员变量的地址）。 那怎么办呢？这个解决方案可以另写一篇文章了。这里只是简单说一下LuaJIT采用的trace compiler方案：首先所有的Lua都会被编译成bytecode，在Interpreter模式下执行，当Interpreter发现某段代码经常被执行，比如for循环代码（是的，大部分性能瓶颈其实都跟循环有关），那么LuaJIT会开启一个记录模式，记录这段代码实际运行每一步的细节（比如里头的变量是什么类型，猜测是数值还是table）。有了这些信息，LuaJIT就可以做优化了：如果a+b发现就是两个数字相加，那就可以优化成数值相加；如果a.xxx就是访问a下面某个固定的字段，那就可以优化成固定的内存访问，不用再走表查询。最后就可以将这段经常执行的代码JIT化。 这里可以看到，第一，Interpreter模式是必须的，无论平台是否允许JIT，都必须先使用Interpreter执行；第二，并非所有代码都会JIT执行，仅仅是部分代码会这样，并且是运行过程中决定的。 三、要在安卓下发挥JIT的威力，必须要解决掉JIT模式下的坑：JIT失败 那么说了JIT怎么运作的，看起来没什么问题呀，为何说不一定更快呢？这里就有另一个大坑：LuaJIT无法保证所有代码都可以JIT化，并且这点只能在尝试编译的过程中才知道。 听起来好像没什么概念。事实上，这种情况的出现，有时是毁灭性的，可以让你的运行速度下降百倍。对，你没看错，是百倍，几ms的代码突然飙到几百ms。具体的感受，可以看看我们之前的技术推文《Unity项目常见Lua解决方案性能比较》中S3的测试数据，一个纯Lua代码的用例（Vector3.Normalize没有经过c#），却出现了巨大的性能差异。而JIT失败的原因非常多，而当你理解背后的原理后会知道，在安卓下JIT失败的可能要比PC上高得多。 根据我们在安卓下的使用来看，最常见的有以下几种，并且后面写上了应对方案。 3.1 可供代码执行的内存空间被耗尽->要么放弃JIT，要么修改LuaJIT的代码 要JIT，就要编译出机器码，放到特定的内存空间。但是arm有一个限制，就是跳转指令只能跳转前后32MB的空间，这导致了一个巨大的问题：LuaJIT生成的代码要保证在一个连续的64MB空间内，如果这个空间被其他东西占用了，LuaJIT就会分配不出用于jit的内存，而目前LuaJIT会疯狂重复尝试编译，最后导致性能处于瘫痪的状态。 虽然网上有一些不修改LuaJIT的方案，在Lua中调用LuaJIT的jit.opt的api尝试将内存空间分配给LuaJIT，但根据我们的测试，在Unity上这样做仍然无法保证所有机器上能够不出问题，因为这些方案的原理要抢在这些内存空间被用于其他用途前全部先分配给LuaJIT，但是uLua可以运行的时候已经是程序初始化非常后期的阶段，这个时候众多的Unity初始化流程可能早已耗光了这块内存空间。相反Cocos-2dx这个问题并不多见，因为LuaJIT运行早，有很大的机会提前抢占内存空间。 无论从代码看还是根据我们的测试以及LuaJIT maillist的反馈来看，这个问题早在2.0.x就存在，更换2.1.0依然无法解决，我们建议，如果项目想要使用jit模式，需要在android工程的Activity入口中就加载LuaJIT，做好内存分配，然后将这个luasate传递给Unity使用。如果不愿意趟这个麻烦，那可以根据项目实际测试的情况，考虑禁用jit模式（见文章第9点）。一般来说，Lua代码越少，遇到这个问题的可能性越低。 3.2 寄存器分配失败->减少local变量、避免过深的调用层次 很不幸的一点是，arm中可用的寄存器比x86少。LuaJIT为了速度，会尽可能用寄存器存储local变量，但是如果local变量太多，寄存器不够用，目前JIT的做法是：放弃治疗（有兴趣可以看看源码中asm_head_side函数的注释）。因此，我们能做的，只有按照官方优化指引说的，避免过多的local变量，或者通过do end来限制local变量的生命周期。 3.3 调用c函数的代码无法JIT->使用ffi，或者使用2.1.0beta2 这里要提醒一点，调用c#，本质也是调用c，所以只要调用c#导出，都是一样的。而这些代码是无法JIT化的，但是LuaJIT有一个利器，叫ffi，使用了ffi导出的c函数在调用的时候是可以JIT化的。 另外，2.1.0beta2开始正式引入了trace stitch，可以将调用c的lua代码独立起来，将其他可以jit的代码jit掉，不过根据作者的说法，这个优化效果依然有限。 3.4 JIT遇到不支持的字节码->少用for in pairs，少用字符串连接 有非常多bytecode或者内部库调用是无法JIT化的，最典型就是for in pairs，以及字符串连接符（2.1.0开始支持JIT）。 具体可以看http://wiki.luajit.org/NYI，只要不是标记yes或者2.1的代码，就不要过多使用。 四、怎么知道自己的代码有没有JIT失败？使用v.lua 完整的LuaJIT的exe版本都会带一个JIT目录，下面有大量LuaJIT的工具，其中有一个v.lua，这是LuaJIT Verbose Mode（另外还有一个很重要的叫p.lua，luajit profiler，后面会提到），可以追踪LuaJIT运行过程中的一些细节，其中就可以帮你追踪JIT失败的情况。 local verbo = require("jit.v") verbo.start() 当你看到以下错误的时候，说明你遇到了JIT失败： failed to allocate mcode memory，对应错误3.1 NYI: register coalescing too complex，对应错误3.2 NYI: C function，对应错误3.3（这个错误在2.1.0beta2中已经移除，因为有trace stitch） NYI: bytecode，对应错误3.4 这在LuaJIT.exe下使用会很正常，但要在Unity下用上需要修改v.lua的代码，把所有out:write输出导向到Debug.Log里。 五、照着LuaJIT的偏好来写Lua代码 最后，趟完LuaJIT本身的深坑，还有一些相对轻松的坑，也就是你如何在写Lua的时候，根据LuaJIT的特性，按照其喜好的方式来写，获得更好的性能 这里可以看我们的另一篇文章《LuaJIT官方性能优化指南和注解》，这里比较详细地说明如何写出适合LuaJIT的Lua代码。 六、如果可以，用传统的local function而非class的方式来写代码 由于cocos2dx时代的推广，目前主流的Lua面向对象实现（例如Cocos-2dx以及uLua的simpleframework集成的）都依赖metatable来调用成员函数，深入读过LuaJIT后就会知道，在Interpreter模式下，查找metatable会产生多一次表查找，而且self:Func()这种写法的性能也远不如先cache再调用的写法：local f = Class.Func; f(self)，因为local cache可以省去表查找的流程，根据我们的测试，Interpreter模式下，结合local cache和移除metatable流程，可以有2~3倍的性能差。 而LuaJIT官方也建议尽可能只调用local function，省去全局查找的时间。比较典型的就是Vector3的主流Lua实现都是基于metatable做的，虽然代码更优雅，更接近面向对象的风格（va:Add(vb)对比Vector3.Add(va, vb)）但是性能会差一些。当然，这点可以根据项目的实际情况来定，不必强求，毕竟要在代码可读性和性能间权衡。我们建议在高频使用的对象中（例如Vector3）使用function风格的写法，而主要的代码可以继续保持class风格的写法。 七、不要过度使用C#回调Lua，这非常慢 目前LuaJIT官方文档（ffi的文档）中建议优先进行Lua调用c，而尽可能避免c回调Lua。当然常用的UI回调因为频次不高所以一般可以放心使用，但是如果是每帧触发的逻辑，那么直接在Lua中完成，比反复从Lua->C->Lua的调用要更快。这里有一篇blog分析，可以参考：http://www.cppblog.com/tdzl2003/archive/2013/02/24/198045.html 八、借助ffi，进一步提升LuaJIT与C/C#交互的性能 ffi是LuaJIT独有的一个神器，用于进行高效的LuaJIT与C交互。其原理是向LuaJIT提供C代码的原型声明，这样LuaJIT就可以直接生成机器码级别的优化代码来与C交互，不再需要传统的Lua API来做交互。 我们进行过简单的测试，利用ffi的交互效率可以有数倍甚至10倍级别的提升（当然具体要视乎参数列表而定），真可谓飞翔的速度。而借助ffi也是可以提高LuaJIT与C#交互的性能。原理是利用ffi调用自己定义的C函数，再从C函数调用C#，从而优化掉LuaJIT到c这一层的性能消耗，而主要留下C到C#的交互消耗。在上一篇中我们提到的300ms优化到200ms，就是利用这个技巧达到的。 必须要注意的是，ffi只有在JIT开启下才能发挥其性能，如果是在iOS下，ffi反而会拖慢性能。所以使用的时候必须要做好快关。 首先，我们在c中定义一个方法，用于将C#的函数注册到c中，以便在c中可以直接调用C#的函数，这样只要LuaJIT可以ffi调用c，也就自然可以调用C#的函数了 void gse_ffi_register_csharp(int id, void* func) { s_reg_funcs[id] = func; } 这里，id是一个你自由分配给C#函数的id，lua通过这个id来决定调用哪个函数。 然后在C#中将C#函数注册到c中 [DllImport(LUADLL, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] public static extern void gse_ffi_register_csharp(int funcid, IntPtr func); public static void gse_ffi_register_v_i1f3(int funcid, f_v_i1f3 func) { gse_ffi_register_csharp(funcid, Marshal.GetFunctionPointerForDelegate(func)); } gse_ffi_register_v_i1f3(1, GObjSetPositionAddTerrainHeight);//将GObjSetPositionAddTerrainHeight注册为id1的函数 然后Lua中使用的时候，这么调用 local ffi = require("ffi") ffi.cdefint gse_ffi_i_f3(int funcid, float f1, float f2, float f3); local funcid = 1 ffi.C.gse_ffi_i_f3(funcid, objID, posx, posy, posz) 就可以从Lua中利用ffi调用C#的函数了 可以类似ToLua，将这个注册流程的代码自动生成。 九、既然LuaJIT坑那么多那么复杂，为什么不用原生Lua？ 无法否认，LuaJIT的JIT模式非常难以驾驭，尤其是其在移动平台上的性能表现不稳定导致在大型工程中很难保证其性能可靠。那是不是干脆转用原生Lua呢？ 我们的建议是，继续使用LuaJIT，但是对于一般的团队而言，使用Interpreter模式。 目前根据我们的测试情况来看，LuaJIT的Interpreter模式夸平台稳定性足够，性能行为也基本接近原生Lua（不会像JIT模式有各种trace compiler带来的坑），但是性能依然比原生Lua有绝对优势（平均可以快3~8倍，虽然不及JIT模式极限几十倍的提升），所以在游戏这种性能敏感的场合下面，我们依然推荐使用LuaJIT，至少使用Interpreter模式。这样项目既可以享受一个相对ok的语言性能，同时又不需要过度投入精力进行Lua语言的优化。 此外，LuaJIT原生提供的profiler也非常有用，更复杂的字节码也更有利于反破解。如果团队有能力解决好LuaJIT的编译以及代码修改维护，LuaJIT还是非常值得推荐的。 不过，LuaJIT目前的更新频率确实在减缓，最新的LuaJIT2.1.0 beta2已经有一年没有新的beta更新（但这个版本目前看也足够稳定），在标准上也基本停留在Lua5.1上，没有5.3里int64/utf8的原生支持，此外由于LuaJIT的平台相关性极强，一旦希望支持的平台存在兼容性问题的话，很可能需要自行解决甚至只能转用原生Lua。所以开发团队需要自己权衡。但从我们的实践情况来看，LuaJIT使用5.1的标准再集成一些外部的int64/utf解决方法就能很好地适应跨平台、国际化的需求，并没有实质的障碍，同时继续享受这个版本的性能优势。 我们的项目，在战斗时同屏规模可达100+角色，在这样的情况下Interpreter的性能依然有相当的压力。所以团队如果决定使用Lua开发，仍然要注意Lua和C #代码的合理分配，高频率的代码尽量由C#完成，Lua负责组装这些功能模块以及编写经常需要热更的代码。 最后，怎么打开Interpreter模式？非常简单，最你执行第一行Lua前面加上。 if jit then jit.off();jit.flush() end 文末，再次感谢招文勇的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 也欢迎大家来积极参与U Sparkle开发者计划，简称"US"，代表你和我，代表UWA和开发者在一起！
动画重定向技术分析及其在Unity中的应用
动画重定向技术是一种时间换空间的思路，在运行时将一套动画根据预计算好的骨骼差异信息，得到目标模型骨架上可以适用的动画数据，就可以生成目标角色的受击动画效果。这种方式既可以减少美术的工作量，又可以增加游戏对策划需求和修改的灵活度。 前言 笔者新的手游项目使用Unity引擎，动画部分要使用重定向技术来实现动画复用。笔者之前在大公司工作的时候对这块了解比较深入，读过Havok引擎在这部分的实现源码，并基于自己的理解，在公司自研的手游引擎中实现了一个简化的版本。本文将从原理层面分析Animation Retargeting技术的实现方式，然后落回到Unity引擎中，记录笔者使用Retargeting时遇到的问题和解决方法。 壹 · 什么是动画重定向 动画重定向，即Animation Retargeting，是一种动画复用的技术。直观上，你可以把它的作用想象成周星驰电影《西游-降魔篇》里的“听话符”——“我做什么，你就跟我做什么”。 听话符 这里给出一个非官方的定义： Retargeting is just the process of “copying” the animation from one skeleton to the other. 简单来说，重定向技术就是一种将动画数据从一个骨架拷贝到另外一个骨架的过程，只是这个所谓的“拷贝”过程，没有Ctrl+C、Ctrl+V这么简单。其实现的方式，也会影响到动画应用到另外一个骨架之后的效果。 贰 · 为什么要使用动画重定向 动画重定向技术在很多地方已经得到很广泛的应用，比如目前3A级主机游戏常用的动作捕捉技术就是基于这个原理来实现的——将真实人物的动作通过图像识别等技术生成动画信息应用到虚拟的角色身上，保存成动画数据。还有Max和Maya这样的建模工具中，也都已经集成了这一技术，用来方便美术把一个动作文件复用到其他骨架上。 那什么类型的游戏开发需要这样的技术呢？我们可以假想，如果要开发类似《街霸》这样的3D格斗游戏，其中有几十个不同体型、姿态各异的角色，战斗过程中会有很多抓技和投技的动作表现，对于这一类型的技能，不同角色的受击动作应该是一致的。 如果使用最常规的制作方法，需要对每一个角色制作攻击动作对应的受击动画，或者需要限制策划设计的受击表现在使用固定姿势、挂接、特效等方式可以实现的范围内。而不甘平庸的策划同学往往需要更加复杂的受击表现，常规的制作方法带来的问题可能有如下三种： 美术工作量较大。 如果有n个不同角色的骨架，有m个需要特殊表现的技能，那么只考虑受击动画就需要m*n个，这个数量级通常会在几千个动作的级别。美术需要制作、导出这些动画文件，需要非常大的工作量。 动画维护困难。 想象下，策划设计了一个需要特殊动作表现的技能，美术加班加点制作出了所有英雄的受击动画，第二天黑着眼圈来上班，策划同学满脸讪笑地走过来说——“哎呀，你看，这个受击动作的第15帧有点不自然，力量感不够强，要你不优化一下？”……每一处修改都需要美术手动同步到其他动作文件上，重新调整导出，这一过程太费时费力。 游戏发布打包文件过大。 动画文件多必然带来这一问题，尤其是在手游中，包体大小通常是安卓渠道发行的一个需要考虑的重要指标。 动画重定向技术就是一种时间换空间的思路，在运行时将一套动画根据预计算好的骨骼差异信息，得到目标模型骨架上可以适用的动画数据，就可以生成目标角色的受击动画效果。这种方式既可以减少美术的工作量，又可以增加游戏对策划需求和修改的灵活度，将需要维护和打包的动画数量从m*n这样的平方级别降低到m个。当然，软件工程中“没有银弹”的定律在这里依旧生效，重定向之后的动画可能由于体型差异等问题在某些情况下无法完全满足策划或者美术的要求，这就需要一些额外的方法或者重新制作部分动画来进行弥补，这部分放在后面进行详述。 叁 · 基本原理 写到这里，其实之前有一点没有说明的是动画重定向技术主要是针对骨骼动画的方案，由骨骼来描述动作信息，用蒙皮来表示模型网格与骨骼之间的关系，从而得到模型最终的样子。对于这块不熟悉的读者可以自己查阅一下相关的资料。 骨骼动画 动画重定向的过程主要是针对骨骼信息，对于蒙皮过程没有任何影响。而动画信息，可以理解为每一帧中所有骨骼数据信息的集合。我们可以这样更加形象地理解：动画就是每一帧为模型制作一个Pose（姿势），在每帧之间的姿势可以通过差值获得。 为了更加清楚地描述动画重定向地原理，我们截取某一帧的姿势来进行分析——在解决了每一帧的姿势重定向之后，整个动画的重定向，也就是在每一帧都进行姿势的重定向即可。 假设有两个模型，它们的骨架分别是A和B，我们拥有A骨架对应的动画数据，想把这个动画数据应用在B骨架上。直接应用是否可以？当A骨架和B骨架完全一样的时候，直接应用是可以的，但通常动画重定向要处理的是骨架不太一样的情况。这些不一样的情况有很多种，比如： a) 骨骼数量不一致； b) 骨骼父子关系不一致； c) 骨骼名称不一致； d) 骨骼本身的长度等数据不一致。 对于前几种不一致情况的处理，我们在基本原理的部分先不进行分析，对于骨骼数量一致、骨骼父子关系也相同这样最为简单的情况，我们来看一下动画重定向的基本方式。 先明确在这种情况下，直接应用A的动画数据到B上会存在的问题。想象一下，A是一个身高为1.8m左右的大人骨架，而B是一个身高为1.5m左右小孩骨架，A的动画信息记录的骨骼位置（Position）、旋转（Rotation）和缩放（Scale）信息都是针对于大人的，比如经过最终的计算，在Idle动画中的某一帧姿势中，模型空间下A的Head这根骨骼可能在距离地面1.7m这样的位置，而同样的动作让一个小孩B来做，也把Head骨骼放置在1.7m的位置，蒙皮之后的结果会非常奇怪，出现拉伸等问题，因为B的身高整个才是1.5m。 为了解决这个问题，或者说一个正确的动画重定向过程，是基于所谓的参考姿势（Reference Pose）的。通常情况下，会把T-Pose作为参考姿势。引入参考姿势之后，动画数据不再直接应用到目标骨架上，而是把动画姿势与参考姿势的差异应用到目标骨架上。a1是A骨架的参考姿势，b1是B骨架的参考姿势，动画中某一帧的姿势是a2，我们想得到的结果是b2，我们认为，a2与参考姿势a1的差异应当和b2与其对应的参考姿势b1的差异相同，即： a2 - a1 = b2 - b1 我们可以得到计算过程为： b2 = a2 - a1 + b2 = a2 + (b1 - a1) 这其中，a1和b1的数据是在游戏发布时就确定的，因此可以进行预先计算好b1-a1的值。要知道这里的加法和减法要转换为每根骨骼的PRS计算，因此还是有不少CPU消耗的。下图给出了使用一个简单整数代替骨骼的PRS数据来模拟动画重定向的计算过程。 简化版本的动画重定向原理 从上图可以看出，在Setup阶段，可以计算出b1-a1的值，然后运行时只需要根据动画数据这个diff值就可以得到最终想要的动画效果。这里需要说明的是，在Havok引擎的实现中，会根据a1和b1两个参考姿势中每根骨骼相对于父骨骼的Position计算出骨骼的长度，比如a1中Head骨骼的长度为1.0，b1中Head骨骼的长度为1.5，计算diff的结果时会把这两个值的比例应用到scale部分，即会给scale * 1.5，来保证最终映射出来骨骼位置的合理性。这部分的实现太过细节，其实只需要做这部分实现的人理解即可，有兴趣的朋友可以留言讨论。 在引擎实现方面，Havok引擎使用了单独的mapping文件来存储b1-a1的数据，并且这部分数据具有一定的可定制性；在我自己后来的实现中，采用的方式是加载模型文件时检查该模型是否需要动画重定向，如果需要则计算一份对应的diff信息放在内存中。两种实现方案的区别不大，只是时间和空间的互换，后者是在需求比较确定，减少美术工作量的考量下选择的实现方式。 总之，基于参考姿势的动画重定向计算，不但可以保证大部分情况下可以有良好的动画效果，而且可以保留目标骨架的一些基本姿势特征，比如驼背、八字脚等，应用得到的话是可以让重定向之后的动画也有这些特种。 肆 · 骨骼映射 前面的基本原理只解决了骨骼长度等数据不一致的情况下的动画重定向问题，在骨骼数量、名称甚至父子关系等都不一致的情况下，该如何处理呢？ 对于手游应用者来说，答案很简单——尽量不要出现上述的这些情况。虽然有各种技术方案可以一定程度上处理和解决上述问题，但目前工程方面并没有非常完美的效果，而且可能在性能、制作流程复杂度上导致一些问题。 应用动画重定向技术的原则应当是——在项目初期做好技术预研、确定使用方法、制定从骨骼结构到骨骼名称等相关的美术规范。在中后期引入这一技术的应用，在美术制作不规范的情况下，就有可能带来较大的额外工作。 这里，针对不同的问题，给出一些解决方案的思路，某些技术细节的实现就不进行详细的说明了。 4.1 骨骼名称不一致 在项目开发中，出于进度的考虑，往往会把不同角色模型和动画的制作外包给不同的外包商，他们对于骨骼的命名可能会不同，比如常见的Biped Head和Bip001 Head。对于CS骨骼，由于在3DS Max中通常美术只会添加不同的前缀，因此可以通过去除前缀的方式进行模糊匹配来做骨骼映射；Unity的做法细节不清楚，但是笔者感觉会根据整个骨架的父子关系和结构来进行映射关系的计算；而对于Bone骨骼，在没有预先定义好类似最大化骨骼这样规范的情况下，非常难通过程序来判断映射关系，可以提供可视化编辑的功能来让美术自己定义它们之间的映射关系。 4.2 骨骼数量不一致 我们之前讨论的映射关系，都是一根骨骼对应一根骨骼的简单映射方式。在骨骼数量不一致时又可以分为两种情况来讨论： 1）在非CS骨骼或者不重要的部分存在多余的骨骼。如果动画文件的骨架中存在多余骨骼，通常的做法是把这些骨骼忽略掉，而如果目标骨架上存在多余的骨骼，即有些骨骼原始动画中并不存在，这其实没有办法为它生成动画，只需要保证其保留在原始姿势的local space当中，即让其跟着父骨骼移动。比如身上的飘带，如果原始动画中没有，在不使用布料系统等物理方案的情况下，只能让其按照参考姿势中的样子，“僵硬”地跟随角色移动。 2）在重要的位置存在骨骼不一致。一个常见的例子是胸部的脊柱，不同的角色可能脊柱骨骼数量不同，从2根到4、5根都很常见。这种情况下，简单的一根骨骼映射到一根骨骼就无法做到很好的效果，如下图所示（使用Havok文档中的例子）： 链式映射 1 我们想让蓝色小人变到黄色小人当前的姿势，蓝色小人胸部只有一根骨骼，而黄色小人有5根，那么让这一根骨骼使用黄色小人那5根骨骼中的任何一根结果都不理想，可能会出现如下图所示的这种结果： 链式映射 2 这种结果最后渲染出来可能是一个头向前倾斜，但是背依然挺直的角色，它脖子处的蒙皮被拉得很长。这不是我们想要的效果，如果使用链式映射，可以做到如下图所示的这种较好的结果： 链式映射 3 注意，这里同时修复了头顶上的两根骨骼没有跟随父骨骼移动的问题。 链式映射要做的就是将多根骨骼组成的骨链A和另外一个骨骼中多根骨骼组成的骨链B进行映射，做到整条B骨链的样子和A骨链的样子相近。 那么链式映射的基本原理就是：首先对齐起始链骨骼，然后再保持骨链原有样子的条件下，尽量对齐它们的结束骨骼。下图给出了一个简单的示例： 链式映射原理 1 上图把两根骨骼形成的骨骼链接映射到四根骨骼形成的姿势上面。 链式映射原理 2 上图把四根骨骼形成的骨骼链接映射到两根骨骼形成的姿势上面。 那么如何对齐结束骨骼呢？这里只简单描述Havok引擎中的计算方法，首先算出从骨链的开始骨骼到结束骨骼的向量，比如A1，B1，然后将这两个向量规范化，计算可以将B1转到A1最小旋转，这个旋转使用四元数表示，描述了一个和两个向量都垂直的轴外加一个旋转角度Theta值。这个四元数的计算过程可以使用向量的点积、叉积和半角正玄公式、二倍角正玄公式得到，避免了三角函数计算这种很耗的计算过程。下图给出了一个简单的计算过程，可以看出其使用向量计算来进行效率上的优化。 简化的旋转计算过程 4.3 骨骼父子关系不一致 当两套骨骼的父子关系都不一致的情况下，其实很难得到正确的映射，简单的不一致可能可以容忍，但是可以想象，把一个人形骨骼的动画映射给四足动物甚至蜘蛛这样的八脚动物，是一件非常难做的事情。 也因为这样的原因，目前大范围应用的动画重定向，基本还是在人形骨骼上，当然，用相同的算法，把四足的战马动画映射到不同的体型的战马上也是可以的。基本的原则是骨架尽量具有更多的相似性，重定向的效果也就会更好。 伍 · Unity引擎中的应用 在了解了基本原理之后，我们来看一下动画重定向技术在工程中的应用。笔者之前使用Havok引擎和实现自研引擎中的相应功能有较多的应用经验，但是Unity引擎还是摸索了一下才找到正确的使用方法，官方文档（https://docs.unity3d.com/Manual/Retargeting.html）给出了一个简单的应用过程和效果截图，但这个文档讲述的过程比较简单，一些注意事项也没有说得很清楚。 5.1 基本使用方法 首先，Unity引擎中动画重定向的实现不是一个直观的方法，而是封装在了Humanoid类型的动画系统里面，也就是必须是人形的骨架、使用Humanoid才可以使用它。Unity没有像前文描述的基本原理那样去定义两套骨架之间的映射关系，而是自己在内部定义一套骨架模板，所有的Avatar骨骼都必须映射到这套模板上才可以由同一个Animator来驱动产生Retargeting之后的动画效果。 Unity中的骨骼映射设置界面 在导入一个模型文件之后，要在这里设置每根骨骼的映射关系，这个映射关系默认会自动建立一套，可以根据需要进行调整，也可以保存和导入配置好的映射关系。 关于如何预览一个重定向之后的动画的效果，笔者还没找到非常方便的办法，只能把模型放到Scene中，设置同一个Animator来观察，在动画文件的预览窗口，如果拖拽另外一个模型文件到其中，并不能预览到正确的效果，这跟我之前预想的不太一样。这里简单说明一下整个设置过程，假设我们想让一个female的模型复用一个male的动画： 导入male的模型，设置动画类型为Humanoid，设置好Avatar上的骨骼映射关系； 导入male的动画文件，设置其类型为Humanoid，讲其Avatar设置为male的avatar； 使用male的动画制作一个Animator； 导入female的模型文件，设置动画类型为Humanoid，同样设置好Avatar上的骨骼映射关系； 可以拖拽一个模型文件到Scene中，然后把它的Animator设置为之前male的动画制作的Animator，即可看到Retargeting之后的效果了。 这一过程比较简单，Unity引擎为用户隐藏了非常多的实现细节，让用户在进行尽量少的配置的情况下使用动画重定向功能。 5.2 遇到的问题记录 在使用过程中，我们遇到了一些问题。 角色的武器或者飘带在使用Humanoid类型的动画系统之后不会移动了，或者移动的位置有了很大的偏差。这时候可以在动画文件的属性设置里，查看Mask下的Transform选项，里面可能存在没有被勾选的骨骼。 Unity动画设置 我们目前的做法是把Transform下的所有骨骼对象都勾选上。 2）角色的武器在动作中出现了乱飘的情况，与手部无法紧密地绑定在一起。这是由于我们最初为了方便美术制作武器的动作，把其父骨骼设置给了盆骨这样一根相对稳定的骨骼，但是经过Retargeting计算之后，由于角色身材不同产生了一些偏差导致。最终我们还是把武器骨骼的父骨骼设置为手部的骨骼，才解决了这一问题。 3）某些角色在重定向之后的动画中表现为脚不贴地，和地面之间有缝隙，原始动画中没有。如果你理解了动画重定向的原理，那么当原始动画没有问题、而重定向之后的动画存在问题时，可能是重定向算法或者是参考姿势这两个因素导致。我们在没有代码授权的前提下无法查看和修改Unity引擎的重定向实现代码，而且Retargeting功能已经发布了这么久，理论上大部分的Bug都应该已经修复了...在Avatar的Configuration界面中，除了设置骨骼的映射关系之外，还可以设置T-Pos的姿势，这里我们可以对参考姿势做微调，如下图所示： Unity中调整参考姿势 注意，Unity定义了一个基本的T-Pos姿势范围，当调整的结果超出范围的时候，会出现红色的提示字样。我们的问题是目标角色的脚步骨骼在参考姿势中没有贴地，导致了重定向之后的问题。当你发现重定向之后的动画有些骨骼的姿势很奇怪，或者想让你的目标角色有一些自己的特有个性(比如外八字脚之类)的时候，可以在这个界面进行调整。 5.3 性能消耗 通过前面的原理分析可以看出，即使在有预计算的情况下，与普通的动画计算，Retargeting的过程还是有一定的CPU消耗的，但是这与通常会造成CPU瓶颈的蒙皮、渲染指令提交等相比，其实消耗并不算大。由于Unity与Retargeting相关的还制作了肌肉控制的功能，我担心这部分会有额外的性能消耗，还专门拜托朋友帮忙询问了Unity大中华区的技术人员，得到的答复是——Humanoid形式的动画系统相对于Generic形式的动画系统虽然有一部分额外的性能消耗，但是Unity内部做了比较好的优化，差别不是很大，因此可以放心使用。我们由于必须使用Retargeting的功能，因此也没有对几套动画系统进行非常详尽的性能对照测试，但从以往游戏开发的经验来看，动画重定向这块通常不会在后期的性能Profile过程中被揪出来要求优化。 总之，虽然动画重定向这套东西已经很熟悉了，但是在Unity中的应用，我们还是摸索的新兵，目前也只是项目的初期，遇到的问题不是很多，如果有朋友有相关的经验可以介绍和分享，还望不吝赐教。 陆 · 其他 如果你去搜Animation Retargeting相关的论文，会发现学术界有很多更加深入的研究，但是很多算法都不适用于当前的游戏开发。而对于差别非常大的骨架，其实也没有必要非要去用重定向的技术，可能美术单独制作动画资源效果会更好。 在之前搜集资料的过程中，读到一篇关于《spore》游戏的论文，觉得比较有意思，《Real-time Motion Retargeting to Highly Varied User-Created Morphologies》。有兴趣的朋友可以去读一下，《孢子》这款游戏使用的实时动画系统中就有Animation Retargeting技术的应用，结合IK（反向运动学）技术，实现了游戏中自定制角色的动画表现，效果非常不错，玩过的朋友可能会有印象。 柒 · 结语 动画重定向系统就像很多的游戏引擎组件一样，是一个可能从外部看上去似乎很深奥复杂的功能，但是深入其实现原理时，却发现其实非常简单。然而，要把这样一个功能封装成一个易用性很强的引擎模块，让用户可以在不理解原理的情况下去轻松地使用它又需要解决很多实际问题，开发各种编辑工具。把一项技术，应用到实际的游戏开发中，就需要这样一个由难入易，再由易到难的过程，这也是做科学研究与做实际工程的区别之一吧。 希望本文可以给需要用到动画重定向技术的朋友以帮助，也欢迎更多的朋友进行讨论和分享。对于游戏开发这样一项工作来说，永远都有学习不完的知识和技术，而技术的积累和沉淀，是把游戏做得更好的有力保障。 这是侑虎科技第228篇原创文章，感谢作者贾伟昊供稿，欢迎转发分享，未经作者授权请勿转载。当然，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者简书：http://www.jianshu.com/u/5ccd10a133d9，作者也是U Sparkle活动参与者，UWA欢迎更多开发朋友加入U Sparkle开发者计划，这个舞台有你更精彩！
VR交互动画短片《拾梦老人》的开发经历
作为VR交互动画短片《拾梦老人》的开发者，笔者从引擎选取、技术创新、填坑之路以及后期展望等多个角度，为我们细细道来了他们的开发故事。 笔者去年写了一篇关于如何打造高逼格耀斑效果的文章，很荣幸得到大家的关注。本人平日乐于分享自己工作中的点滴技术心得，欢迎大家联系我进行技术相关的交流学习。 今年笔者在PintaStudio，这是我在中科院的同学创立的一家VR电影工作室，我们希望用游戏引擎实时渲染的方式来表达艺术家的思想。同时，我们制作的VR沉浸式感受也是一种全新的观影体验。接下来，我将简述整个开发的历程。 VR交互动画短片《拾梦老人》剧照 一、引擎的选择 （Unity VS UE） 立项阶段，我们遇到了引擎选择上的分歧。我们想做的是重量级大作，所以最初的想法是采用一个重量级引擎。早期做端游时，我们了解得最多的是CRY ENGINE（下称“CE”）和UNREAL ENGINE（下称“UE”）了，这两个引擎就是引擎界的“西门吹雪”和“叶孤城”，长期霸占着国外的大作市场。CE这些年在国内推广不利，所以在最初阶段，更多的小伙伴希望用UE来做我们这个电影。我的加入，给团队带来了新的选择—Unity。相比前两个引擎，Unity可以说是后起之秀，尤其近3年手游的兴起，才把Unity引入大众的视线。我们主要对UE和Unity两个引擎进行了比较： 在视觉效果上，UE的完成度更高。引擎自带了比较多的效果如Light Propagation、各种Volume效果；UE的实时光照用的是经典PBR光照模型，更加接近艺术家们要求的Maya效果；UE的ShaderTree，更是给人一种“屌丝图形码工可以退休”的感觉。反观Unity，自带的效果比较有限(不考虑庞大逆天的AssetStore情况下)，而且引擎中增加了一些无端端的优化，造成了一些视觉上的Bug，遭到一些艺术家的诟病。 性能上，UE没有对所有模块做全部测试，但是Unity经历了90%以上手游的考验，相信可以保证影片的流畅性；同时因为有更多的移动开发案例，所以小胜UE。 操作上，UE更强大的功能必然会导致编译时间较长；对于喜欢改装引擎的老司机，修改UE代码需要对它所有的模块都熟知；加上是基于C++的开发，程序需要格外小心内存、指针等各种问题。再看Unity，Unity把一些固定代码做了封装，改装引擎最多只能是对某些模块进行重写(当然，这样改装的结果可能导致性能略低，但是对于我们项目而言，不涉及过多硬件交互)，不太容易出现问题；Unity是基于C#的开发，程序也相对耐操一些，对于支持VR的PC硬件，内存不是最致命的问题，但怕GC太多。 综上所述，我建议团队采用Unity引擎。尽管后来被几个大坑坑得鼻青脸肿，但是从项目进度以及结果来看，这个引擎基本上达到了我们想要的效果。 二、技术创新 Unity引擎本身的画质相对来说是较为简朴的，但是对于电影来说，艺术家希望能有一些更丰富的画面表现。我之前在公司运营团队写的一篇文章里也简单提过，大家可以参考： https://mp.weixin.qq.com/s/TozfSpORF8mb89pao-t4rg 在这里我们举个简单小例子: 如上图这个光影效果，上面窗户衍射效果就有很多种实现方法。先对Depthmap做边缘的blur，然后计算光照是一种办法；直接对Render Target做局部的bloom也是一种方法；OffScreenRenderTarget，渲染一张低分辨率的光照贴图，然后合成回MainTarget，也不失为一种好方法。 对于地上阴影的边缘效果，怎么实现呢？CookieTexture？错误，你没有办法用CookeTexture去解决不规则的阴影衍射，更何况阴影的边缘颜色是艺术家幻想的。那怎么办？其实不难，它是软阴影算法的变种，所以，利用概率采样ShadowMap的方法得到阴影边缘，然后在边缘地区做颜色叠加即可实现。 我们再举一个小例子： 这狗的耳朵是不是有点透明？其实这就是个SSS效果，实现方法是：对于一个表面，沿着视线方向做一个延伸，取到次表面的光照，再叠加到前表面的颜色上。仔细看Unity树叶的Shader（Unity内置Terrain里的Shader），有个变量backcontrib，是把树叶背面的颜色叠到前面来，其实也是sss的变种。 三、重重阻碍 我们项目的开发进程充满艰辛，在此列举部分，给可能要入坑的同学提个醒： 1. 普通物件Standard材质 因为进度原因，我们仍然采用了Unity自带Standard里的PBR光照模型，这个光照模型是经典模型的简化版，对于大多数渲染对象来说还是可行的。但Unity自带的StandardShader在支持半透物件时，就会出现瘫痪的情况。如下图： 左图为正确效果，右图为UnityStandard的错误效果。这是由于StandardShader里Transparent 没有 Z Buffer 导致。那么有同学又会问“我用cut out不就行了么？”但是cut out是用RenderQueue来解决问题的，我希望上面这个腿和靴子来个半透明效果，它就没办法实现了。比如下图这个淡出效果: 此外，Unity还有一个致命的“优化”，就是在OpaqueQueue之后会把ShadowMap给销毁掉。所以半透明物件无法接受阴影。另外，关于点光源，如果光源范围内没有任何物体，Unity会把Lightmap也“优化”掉。类似这样的小细节Bug比较多，但需求不会因为引擎的Bug而减少或者降低标准。一个坑一个坑地填起来，也花费了我们相当大的精力。 2. 特效 如果说普通物件的材质是被Unity坑了一下，那么特效就是被VR的SDK给坑得鼻青脸肿。当时我们用的是HTC VIVE的Steam VR的SDK。研发完成之后才发现，这个SDK对RenderCommand和OffScreenCamera支持极差(OffScreenCamera没有办法左右眼矫正)，也就是说所有的特效必须写在固定管线里，比如这种Volumetric Light。我们需要离线烘焙好ShadowMap，然后再前向渲染时，用RayTrace去模拟它的attention。 上面两张图中，左图是正确的效果，右图是SteamVR里可能看到的效果（因为OffScreen Camera位置没有经过左右眼矫正，所以位置看上去是偏移的，甚至不在屏幕里）。 3. 其他 在VR的开发中，相机也是个坑。因为传感器会实时地重置相机位置，所以你没有办法去动态改变相机，只能通过反向移动场景的方式来实现相机的动态切换。这也消耗了我们很多的时间来填这个坑。 四、展望 Unity 引擎自带的渲染模块基本都有些许欠缺，但是近年来随着Compute Shader、Render Command这些接口的提出，给我们引擎开发者提供了很大的可扩展空间。正如我多次在UWA技术群（465082844）里呼吁的：引擎是用来帮助开发者减少工作量的，不是用来完全依靠的。没有一款引擎能满足所有项目都通用的需求，所有引擎都需要根据不同的项目来做修改。一个不会造发动机的人，可以开车，甚至简单地维修汽车，但是你没法成为造车场的老司机或者职业赛车手。 附《拾梦老人》1分钟唯美预告片： 这是侑虎科技第226篇原创文章，感谢作者张言丰（资深TA，QQ：306566807）供稿。当然，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者也是U Sparkle活动参与者，UWA欢迎更多开发朋友加入U Sparkle开发者计划，这个舞台有你更精彩！
场景中有很多人物动画模型的时候，性能会产生大量开销，其中很大一部分来自于骨骼动画。此文推荐的方法是将CPU中的蒙皮工作转移到GPU中进行，从而能够较大地提升多角色场景的运行效率，在大规模群体动画模拟如MMO、RTS等游戏中有较大的应用性。 一、起因 我们知道，场景中有很多人物动画模型的时候，性能会产生大量开销。这些开销除了 Draw Call 外，很大一部分来自于骨骼动画。Unity 内置了 GPU Skinning 功能，但笔者测试下来并没有对整体性能有任何提升，反而增加了不少。有很多种方法来减小骨骼动画的开销，每一种方法都有其利弊，都不是万金油，这里介绍的方法同样如此。其实本质还是由我们自己来实现 GPU Skinning，只是和 Unity 内置的 GPU Skinning 有所区别。 使用了 ShadowGun中的角色模型 开启 Unity 内置的 GPU Skinning 从上图中可以看到，Unity 调用到了OpenGL ES 的 Transform Feedback 接口，这个接口至少要在 OpenGL ES 3.0 中才有。笔者理解的 Transform Feedback，就是将大批的数据传递给 Vertex Shader，将 GPU 计算过后的结果通过一个 Buffer Object 返回到 CPU 中，CPU 再从 Buffer Object 读取数据（或直接将 Buffer Object 传递给下一步），在随后步骤中使用。显然，在骨骼动画中，TransformFeedback 负责骨骼变换，Unity 将变换后的结果拿来再进行 GPU 蒙皮操作。 这次我们要动手实现的就是这个过程，但是不使用 Transform Feedback，因为要保证在 OpenGL ES 2.0 上也能良好运行，况且Unity引擎也没有提供这么底层的接口。 大致的步骤如下： 将骨骼动画数据序列化到自定义的数据结构中。这么做是因为这样能完全摆脱 Animation 的束缚，并且可以做到 Optimize GameObjects（Unity 中一个功能，在不丢失绑点的情况下将骨骼的层级结构 GameObjects 完全去掉，减少开销）； 在 CPU 中进行骨骼变换； 将骨骼变换的结果传递给 GPU，进行蒙皮。 很简单的三大步骤，对于传统的骨骼动画来说没有任何特殊情况，下面我会对其中的每一步展开说明，并将其中的细节描述清楚。 二、实现 提取骨骼动画数据 Unity 中的 Animation 数据 这个步骤的目的就是将这些数据提取出来，存储到自定义的数据结构中。代码大致如下： 其中有两个注意点。第一，要清楚 AnimationCurve 中提取出来的旋转量是欧拉角还是四元数。这里我一开始就弄错了，想当然认为是欧拉角，所以随后计算得到的结果也就错了。第二，用来旋转的四元数，必须是单位四元数（模是1），否则你会得到 Unity 的一个报错信息。 以上的代码中，我将每一帧的数据以 30fps 的频率直接采样了出来，其实也可以不采样出来，而是等需要的时候再从 AnimationCurve 中采样，这样会更平滑但是运行时的计算量也更多了。 骨骼变换 骨骼变换是所有代码的核心部分了，看似挺复杂，其实想清楚后代码量是最少的: 简单来说骨骼变换就是一个矩阵乘法，比如 bone0（简写为b0） 是 bone1（简写为b1）的父骨骼： 注意这里是矩阵左乘（从右往左读），trs 是 Matrix4x4.TRS，也就是从 AnimationCurve 采样到的数据。 Bindpose 的作用是将模型空间中的顶点坐标变换到骨骼空间中（是骨骼矩阵的逆矩阵），然后应用当前骨骼的变换，沿着层级关系一层层地变换下去。 蒙皮 蒙皮CPU部分的代码如下： 由于骨骼数量固定为 24，所以图中的 96 = 24 x 4 使用 SetMatrixArray 其实有点浪费了，因为对于一个 4x4 的矩阵（四个 float4 ）来说，最后一维永远是 （0, 0, 0, 1），所以可以使用 3x4 的矩阵（三个float4）代替，这样就减少了数据传递的压力。 现在所有的骨骼变换矩阵已经传递到 Shader 中了，就可以使用这些数据来进行蒙皮（变换顶点坐标）。 三、改进 此时所有角色的动作都是同步的。接下来进行改进，不再使用 uniform array 的方式来传递数据，而是将骨骼动画数据存储到纹理中，并加以一定的差异化，避免所有角色的动作完全同步的问题。在运行的最开始，将所有帧的动画数据存储到纹理中，代码如下： Shader中的蒙皮代码相应变为： 以上就是笔者实现 GPU Skinning 的细节。但没有一种方法是完美的，作为能够减少骨骼动画开销的备选方案之一，在恰当的情况下使用会大大地提高性能。 四、测试 为了进一步验证该方案在移动设备上的可行性，UWA在真机上进行如下了实验。 我们在一个空场景中放置一定数目的模型播放动画，对 Mecanim 和 GPU Skinning 的运行效率进行对比。模型取自 ShadowGun，具有2600面片，24根骨骼。使用 Mecanim 时，模型使用 Generic 模式，并且使用 Optimize GameObject。在红米Note2运行1000帧的数据如下： FPS 变化 测试场景 CPU 耗时数据 上图是GPU Skinning方案在场景中存在300个角色时的主线程 CPU 耗时数据。不同角色数目的平均每帧 CPU 耗时（主线程）如下： 从数据可以看出，不论从整体的 FPS还是主线程平均每帧的 CPU 耗时，GPU Skinning都表现出了更好的性能，从而可以让宝贵的 CPU 耗时用于更多的游戏逻辑。 五、优点和局限性 该方法将CPU中的蒙皮工作转移到 GPU 中进行，真机上的测试数据验证了该方法能够较大地提升多角色场景的运行效率。该方法具备以下优点： 极大地降低 MeshSkinning.Render 的CPU耗时，同时还可以去除对 Animator 组件的依赖，从而完全避免 MeshSkinning.Update 和 Animator.Update 的 CPU 占用； 通过纹理保存动画数据，只需要少量内存开销即可带来巨大运行效率提升； 适用于大规模群体动画模拟，如 MMO、RTS 等游戏类型。 当然，该方法在当前也存在如下局限性： 增加 GPU 运算负担； 当前的 Shader 实现中使用了 tex2Dlod，该 API 在某些低端机型上可能存在适配问题； 目前还无法直接处理动画事件、动画融合等操作，需要研发团队进行进一步开发。 这是侑虎科技第222篇原创文章，感谢作者程可汗供稿提供了优化思路以及相关案例。同时，UWA根据作者提供的案例在不同的移动设备上进行测试和对比，并总结成此文，希望对使用骨骼动画的朋友有所借鉴。当然，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 相关Github：https://github.com/chengkehan/GPUSkinning ，作者也是U Sparkle活动参与者，UWA欢迎更多开发朋友加入U Sparkle开发者计划，这个舞台有你更精彩！
最近笔者参考Unity官方介绍资源管理机制和Unity序列化的一些文章做了下AssetBundle打包相关的优化，成功实现了零冗余打包，下面和大家分享一下实现的过程。 一、问题概述 相比Unity 4.x版本里AssetBundle（以下简称ab）打包复杂的API，Unity 5.x版本里提供了更加人性化的依赖自动管理机制——对指定打包的资源，Unity会自动收集并分析其依赖的资源，如果该资源依赖的某个资源没有被显式指定打包到ab中，就将其依赖的这个资源打包进该资源所在的ab里；如果已经被指定打包进其他ab里，那么这两个ab之间就会构成依赖关系，加载ab时，先加载其依赖的ab即可。 这一套依赖管理机制使用方便的同时也会带来一个问题：如果两个ab A和B中的一些资源都依赖了一个没有被指定要打包的资源C，那么C就会同时被打进ab A和B中，造成资源的冗余，增大ab和安装包的体积。而这个被A，B依赖的资源C又可以分为两种类型，一种是Assets下外部导入的资源，即开发者导入或创建的资源；另一种则是Unity内置的资源，例如内置的Shader，Default-Material和UGUI一些组件如Image用的一些纹理资源等等。因此要解决资源冗余的问题，就要分别对这两种被依赖的资源进行处理。下图是我们的一个项目优化前使用UWA提供的资源检测工具检测到的资源冗余情况，可以发现问题较为严重： 二、处理被依赖的外部资源 对于没有被指定打包的外部资源，如果多个ab包依赖了它，打包时该资源就会被多次打包进依赖它的ab包中，造成冗余。解决方案就是将这些被多个ab包依赖的资源打包到一个公共ab包中。处理过程如下： 使用EditorUtility.CollectDependencies()得到ab依赖的所有资源的路径； 统计资源被所有ab引用的次数，将被多个ab引用的资源打包为公共ab包。 需要注意的地方： EditorUtility.CollectDependencies()收集到的资源包含了脚本，dll和编辑器资源，这些资源无需打进ab中。 更详细的步骤可以参考这篇文章：如何优化AssetBundle资源。 至此，再次进行检测我们可以发现冗余的资源数大大减少，冗余的外部资源已经被消灭，只剩下一些冗余的Unity内置资源，接下来我们再来处理这些冗余的内置资源。 三、处理被依赖的内部资源 经过上面的处理，ab中为什么还会有冗余的内置资源？我们来看上面获得ab依赖资源的过程，对于外部资源，可以使用AssetDataBase.GetAssetPath()获得该资源在Assets下的路径；但是对于内置资源，获取的路径却始终是"Resources/unity_builtin_extra"（后面会提到另外一种情况），unity_builtin_extra文件在Assets下并不可见，但会出现在安装包Data/Resources下，可以猜测unity_builtin_extra是Unity内部使用的一个资源库，类似于ab，但无法像对待外置资源那样对其进行打包。因此解决问题的关键就是修改这些资源对内部资源的引用，让其引用外部的资源。 第一种方案是不使用内置资源，该方案理论上可行实际上几乎不可行，例如一些Shader的FallBack是Leagcy VertexLit，使用了该Shader的资源会依赖Leagcy VertexLit（Shader也会依赖FallBack Shader）；Unity自带的3D Object默认使用了Default-Material材质，粒子系统则默认使用了Default-Particle，UGUI一些则组件会用到Background这些内置Sprite等等。 第二种方案则是提取出Unity内置的资源，在打ab前进行预处理，修改引用了内置资源的资源使其引用提取出的那一份资源，接着按处理外部资源的流程进行打包，这样被多次引用的“内置资源”（这里指的是提取出的那一份内置资源）可以被正确的打入ab包，打包完成后再将之前步骤修改过的引用还原即可。这样即不影响项目的正常开发，也从根本上避免了内置资源的冗余。该方案地实现步骤如下： 1. 提取出Unity内置的资源 使用AssetDataBase.LoadAllAssetsAtPath() 可以加载出"Resources/unity_builtin_extra"下所有的Object，可以发现共有4种类型的资源：Shader，Material，Texture以及Sprite。对于内置Shader，我们可以直接从Unity官方网站下载；而对于后三种，可以通过AssetDataBase提供的相关API来进行创建： Object[] UnityAssets = AssetDatabase.LoadAllAssetsAtPath("Resources/unity_builtin_extra"); foreach (var asset in UnityAssets) { // create asset... } 提取出内置资源之后，我们的做法是将shader和其他资源做成两个压缩包放在项目Editor下，在打ab前解压出来随后进行相关的修改，最后再删除解压出来的内置资源。 2. 修改相关资源的引用（难点） 首先我们将Unity资源分为两种类型： 基本类型： Mesh，Texture，AudioClip，Anim等不会引用其他资源的类型 复合类型： 能引用其他资源的类型，有4种：.unity，.prefab，.material，.asset。 参考Unity Manual上介绍Unity序列化的章节，我们可以了解到复合类型的资源本质上都是使用YAML这种标记语言编写的序列化文件，用于描述Unity中组件的各种关系，也包含对依赖关系的描述。将EditorSettings下的Asset Serialization Mode设置为Force Text后，在操作系统的资源管理器下可以以文本文件的形式查看这类文件的内容，示例为Default-Material.mat文件的内容： %YAML 1.1 %TAG !u! tag:unity3d.com,2011: --- !u!21 &2100000 Material: serializedVersion: 6 m_ObjectHideFlags: 0 m_PrefabParentObject: {fileID: 0} m_PrefabInternal: {fileID: 0} m_Name: Default-Diffuse m_Shader: {fileID: 7, guid: 0000000000000000f000000000000000, type: 0} 结合EditorUtility.CollectDependencies()对比分析，可以发现以下格式描述了一条对其他资源的引用： {fileID: xxxxxx, guid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, type: x} fileId和guid标识了是被引用的资源，而type则表示被引用资源的类型。通过进一步的实验推测出0代表Unity内置资源，2代表复合类型的外部资源，3代表了基础类型的外部资源以及Missing Reference的情况，1的含义未知。 在Unity中，每个资源导入或创建时都会被分配一个唯一的guid和fileID，guid可以通过AssetDataBase.AssetPathToGUID()获得，而Unity并没有提供获得file ID的API，只能将Inspector面版设置为Debug模式看到资源的file ID。不过我们依然可以通过反射配合SerializedObject取得file ID的值： private static PropertyInfo inspectorMode = typeof(SerializedObject).GetProperty("inspectorMode", BindingFlags.NonPublic | BindingFlags.Instance); public static long GetFileID(this Object target) { SerializedObject serializedObject = new SerializedObject(target); inspectorMode.SetValue(serializedObject, InspectorMode.Debug, null); SerializedProperty localIdProp = serializedObject.FindProperty("m_LocalIdentfierInFile"); return localIdProp.longValue; } 在以上分析的基础上，我们可以实现以下两个功能： 获得任意资源（包含内置）的file ID和guid，并能根据file ID和guid反向得到对应的资源。 直接读写YAML文件（只有复合类型才是YAML文件）实现修改复合类型资源对其他资源的引用。 最后利用以上两个功能来实现替换Unity内置资源，步骤如下： 第一步：解压提取出的内置资源，建立起提取出的内置资源于内置资源的MetaData（封装了guid和file ID）的映射关系。 第二步：进入处理外置资源的流程，增加一步对复合类型资源及其依赖的复合类型资源是否引用内置资源的检测，如果发现引用则读取对应的YAML文件修改fileID和guid进行替换，并将修改记录下来以便还原。 第三步：等待打包完毕，恢复各项对资源引用的修改，清理解压出来的内置文件。 至此，我们再进行资源冗余检测，可以发现内置资源的冗余也减少了很多，但是还是有个别的残留： 进一步的探索后，发现Unity具有两种内置资源，一套是"Resources/unity_builtin_extra"，主要包含了是Shader，Material，UGUI的Sprite和Sprite对应的Texture，另外一套资源的路径是"Library/unity default resources"，同样可以使用AssetDataBase.LoadAllAssetsAtPath()来加载，主要包含是GUI，内置Mesh这些资源以及一些未知的资源。两者的guid也各不相同： Resources/unity_builtin_extra下资源对应的guid始终为0000000000000000f000000000000000。 Library/unity default resources下资源对应的guid始终为0000000000000000e000000000000000。 个别资源冗余的原因是中发现一些本应属于Resources/unity_builtin_extra的Material和Shader，被引用时guid却是0000000000000000e000000000000000，尝试将其修改为0000000000000000f000000000000000后发现并无差异。具体原因不明，猜测和Unity的版本、开发平台（Windows，Mac）有一定的关系。为了解决这个问题，我们对Material和Shader做了“兼容性”处理，在建立内置资源与提取出的内置资源映射关系时，手动添加一份对Library/unity default resources的映射。最终测试结果： 四、总结 优点：使用了引擎内部的一些机制，具有高度的可控性，在框架层面上实现了零冗余打包。 缺点：依赖于引擎的内部实现，每次升级版本后都需要加以维护；相关资料匮乏，遇到问题不易解决。 这是侑虎科技第202篇原创文章，作者张迪，博客：http://blog.csdn.net/zhangdi2017。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 同时，作者也是U Sparkle活动参与者，UWA欢迎更多开发朋友加入U Sparkle开发者计划，这个舞台有你更精彩！
内存是Unity手游的硬伤，如果没有做好内存的相关管理和详细的测试，游戏极有可能出现卡顿、闪退等影响用户体验的现象。在此，笔者为我们介绍了一些Unity手游内存分析和测试过程中比较实用的测试场景案例、分析方法和解决方案等。 内存是Unity手游的硬伤，如果没有做好内存的相关管理和详细的测试，游戏极有可能出现卡顿、闪退等影响用户体验的现象。在此，笔者为我们介绍了一些Unity手游内存分析和测试过程中比较实用的测试场景案例、分析方法和解决方案等。由于Android和iOS分析思路比较类似，在这里我们仅拿iOS内存分析和测试为例，供大家参考。 测试目标 保证1G内存的iOS设备（iPhone 6、iPhone 6 Plus等）可在长时间持续游戏不闪退，同时优化已知的内存占用过高问题。 测试分析 由于已知游戏内存占用过高，针对这一问题，要搭建测试环境，模拟玩家的实际操作以及外网的实际情况，测试内存的使用情况。一般分为基础测试、压力测试和其他测试。 基础测试，即各种基本操作测试，比如场景切换操作、UI界面操作等； 压力测试，即多角色操作测试，比如角色的加载、角色的复杂程度、技能和动作的释放、上线下线操作等等，比如模拟多角色野外场景压力测试等； 其他测试，比如Xcode的相关工具使用分析等； 在测试中统计各操作对内存增长的贡献，分析可能的原因，并基于测试结果提出合理的优化方案。 测试思路 ◆◆◆ 基础测试 对于基础测试这一部分，可做的测试较多，我们仅拿UI界面操作测试和场景切换测试为例。 1. UI界面操作测试 主角进入指定场景的固定位置，尽量重复操作所有能操作的UI界面，采样对比前后的内存增长。 测试结果： 对比两次采样结果，单纯的界面操作行为，内存总共增长了140M，其中增长最多的是UITexture，剩余的主要是AnimationClip、AnimatorController和其他Texture等。 测试结果分析： 为何UITexture界面贴图会占用这么多的内存呢？ 经分析，为了解决Android平台下打开界面响应过慢的问题，我们对UI界面采用了缓存机制。但是在iOS平台下，首先iPhone的反应速度要优于Android；其次iOS平台（如iPhone 6内存只有1G的设备）对应用程序的内存使用占比控制比Android平台更为严格，超过一定阀值系统会自动结束该进程。因此在iOS平台下，速度与内存要兼顾，尽可能节约对内存的使用。 针对这个问题可以修改界面缓存策略： 根据界面优先级、重要性进行划分，择优缓存； 部分大图集拆小、允许少量小贴图元素冗余； 在后续的优化中，程序修改了界面缓存策略，单纯UITexture方面就减少了约80M！如果你的项目也有这方面的需求，非常值得一试哦！ 2. 场景切换测试 指定A场景的一个固定位置，主角从A场景切换到B场景，然后从B场景切换到A场景，重复切换多次后，最终回到A场景的固定位置，采样对比前后的内存增长。 测试结果： 对比场景切换前后的内存使用情况，发现无明显的变化。 测试结果分析： 场景切换前后内存无增长，基本保证了切换场景逻辑无内存泄露。反之，在测试中如果内存增加较多，则需要查看代码逻辑修复问题。 ◆◆◆ 压力测试 对于压力测试这一部分，由于情况比较多，操作比较复杂，所以要仔细测试。 为何要模拟外网真实环境进行测试呢？ 首先就是可能无外网真实环境可用，这个没有办法，只能模拟；其次是外网真实环境过于复杂不利于有效的分析；再次就是外网真实环境不能随时重现利用，并长时间供测试人员分析，所以要尽量去模拟外网环境。 我们就以模拟多角色野外场景压力测试为例，进行迭代优化。由于需要模拟多人PK的情景，所以我们自己制作了机器人来方便定位并分析多人情况下的性能和内存问题。 1. 模拟多角色野外场景压力测试 模拟测试场景一： 选中一个野外场景A，主角和大量随机门派、随机外观的机器人进入场景，随机释放技能，然后机器人全部下线。分不同情况采集数据对比内存增长，在机器人全部下线后，主角切换到场景B，再切回测试场景A的原始位置，采集数据对比内存增长。 测试结果： 机器人上线后各种不同场景下的内容变化情况如下： 机器人静止时，内存对比空场景，增长97.5M； 机器人释放技能平稳后，内存对比空场景，增长119.4M； 机器人全部下线，内存对比空场景，依然增长98.3M； 机器人全部下线后，主角切换场景，再切回测试场景，内存对比空场景，基本无增长。 测试结果分析： 通过上述的测试及结果,结果分析及可获得的信息如下： 机器人全部下线后，由于大部分资源进入缓存池，故内存有增长是正常的； 机器人全部下线并切换场景后，对比空场景基本无增长，说明缓存池已全部卸载，故切换场景的缓存池释放逻辑正确、无泄漏； 角色相关的绝大部分资源保存至缓存池，切换场景后，所有进入缓存池的资源全部卸载，这一过程看似合理，但存在风险隐患：单场景内存峰值存在过高的风险。 单场景内存峰值存在过高的风险，究竟会有多高、缓存池到底有无泄露呢？ 我们来进一步优化当前的测试环境：既然资源会进缓存池，那么我们放大它的影响，让资源不断进出缓存池。复杂化我们的测试场景，增加机器人不断上下线操作。 2. 模拟多角色野外场景压力测试 2.0 模拟测试场景二： 迭代优化“模拟测试场景一”，选中一个野外场景A，主角和大量随机门派、随机外观的机器人进入场景，在随机时间点，机器人不断的上下线，在线期间随机释放技能，每隔五分钟采样数据。 测试结果： a. 采样数据： 13min 后游戏闪退。 b. 数据截图： 5:15:37数据截图 5:20:55数据截图 5:25:02数据截图 测试结果分析： Material、AnimatorOverrideController、Mesh均在持续增长，我们的缓存池存在严重的资源泄露。剩下的问题就是解决泄露啦，相信我们程序GG，一定可以快速解决问题的！ 在解决泄露问题后，我们的内存就真的没问题了吗？ 我们需要进一步验证结果。在“多角色测试场景二”的基础上，我们还可以做些什么呢？和外网玩家实际情况相比，我们是不是还欠缺了一些东西呢？是的，比如频繁的聊天、UI界面操作、在地图内跑动等，再次复杂化测试场景。 3. 模拟多角色野外场景压力测试 3.0 模拟测试场景三： 迭代优化“模拟测试场景二”，选中一个野外场景A，主角和大量随机门派、随机外观的机器人进入场景，在随机时间点，机器人不断的上下线，在线期间随机释放技能、不停喊话，主角在野外场景中绕地图两圈后，选择固定点站立不动，用低配手机开到游戏内的最高配置，挂机半小时后，尽量重复操作可操作UI界面后，继续挂机半小时以上，间隔一定时间采集数据。 测试结果： 全程无闪退，内存峰值不超标。 测试结果分析： 为何要用低配手机开高配模式去测试呢？ 因为外网玩家的环境总比我们内部模拟的环境要复杂的多，所以要尽可能以高标准要求整个游戏的质量。 为何要绕地图两周呢？ 因为绕地图两周后，地图大部分资源就会进入缓存池中，内存会上升。 为何要挂机半小时后再去重复操作可操作的UI界面呢？ 因为地图资源和角色资源该进缓存池的资源基本都进去了，再去操作UI界面，才是真正的内存压力瓶颈，可以撑过这个内存瓶颈，才能基本保证内存方面基本无重大问题。 这样复杂的测试环境下，低配手机若可以保证连续运行一个小时以上无闪退，内存峰值不超标，就基本保证了游戏内存方面无重大问题了。至此，该测试环境就可以作为发布版本前，必做的客户端内存压测案例之一了。 ◆◆◆ 其他测试 Unity手游需要做优化的东西非常非常多，而内存测试仅是其中的一个方向。本文所举案例中的大部分都可以用Unity自带的Profiler或插件进行测试，但这只能针对Unity自身可以抓取到的资源进行分析。若要分析整体内存的详细情况则需使用Xcode的相关工具。 通过Debug Navigator来观察App的Memory、CPU、FPS等整体情况； 通过Leaks分析内存泄漏； 通过AllocationsInstruments来收集所有的分配信息，比如：第三方库或服务的分配情况、苹果图形硬件驱动部分 (显存共享)；Lua VM 分配和管理部分等。 Allocations Instruments收集所有的分配信息后，我们就可以针对分配次数多和分配尺寸大的模块进一步优化。同时，还需关注分配效率，因为分配得过于零碎，会导致利用率不高，往往还没有达到内存阀值就已经闪退了。 此外，还有一个便捷的途径，就是请UWA的专业团队来帮忙给项目做全方位的优化，包括性能诊断与优化、资源检测与分析等。该团队为我们项目做过优化，专业、细致、深入，整个过程收益颇多，有需求的团队不妨一试！（小编语：谢谢作者对UWA的支持和厚爱！） 鉴于此次的优化内存使用的经验与教训，作为测试人员，一定要认真细致地制定测试步骤并严格执行，善于从各种数据中理清头绪并定位出问题根本原因，以优化目标为导向，以当前的测试结果为修正值，不断地迭代优化测试环境，这样才能保证性能测试结果的正确性、发现项目中存在的严重问题、给出合理的优化方案及建议并最终保证游戏的质量。 以上的测试及观点，不免有遗漏或者不周、不妥的地方，欢迎拍砖、批评指正！ 这是侑虎科技第188篇原创文章，作者潘亚楠，联系QQ：346452238。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 同时，作者也是U Sparkle活动参与者哦，UWA欢迎更多开发朋友加入U Sparkle开发者计划，这个舞台有你更精彩！
近几年手游大热，Unity + Lua更是手游行业的标配技术。如何有效利用有限的硬件条件，实现当下高品质、高规格的游戏产品是持久不变的话题。本文将介绍一种消除配置文件中冗余数据，达到压缩和优化数据存储的方法。 ◆◆◆ 简单介绍 Lua作为一个极为精简的嵌入型脚本语言，已经广泛地用在了游戏业。Lua的存在一般是两种场合，一种用于实现游戏上层业务逻辑，一种则利用了Lua语言本身灵活简单的数据表达能力而被广大程序员用于数据的存储，也就是常说的配置文件。 一般来说配置文件的初始来源是策划维护着的、有着一定格式约束的Excel表格，经由程序员提供的导出工具，把Excel的表格数据导出成为游戏能直接读取使用的Lua源码。 这些源码文件以Lua table的形式存储与Excel等价的数据，通常可以简单把这个配置表看成是一组2维数组，转换成配置就是一个key（Excel第一列）对应一组子数据（Excel中一行），那么整个配置数据就是一个大表包含着若干小表，如下： 原始配置表： 转换成Lua后大致是这样： local mapsetting = { [1] = { name = "map1", useminimap = "map1000", show_name = "十里桃林", cam_pitch = 40, cam_yaw = 230, cam_dis = 10, pkflag_support = 1, pkflag_punish = 1, maxnum = 50, scheduleConf = {}, scriptId = {}, ... }, [2] = { name = "map2", useminimap = "map1000", show_name = "碧波潭", cam_pitch = 40, cam_yaw = 230, cam_dis = 10, pkflag_support = 1, pkflag_punish = 1, maxnum = 50, scheduleConf = {}, scriptId = {10002}, ... }, [3] = { name = "map3", useminimap = "map1000", show_name = "梵音谷", cam_pitch = 40, cam_yaw = 230, cam_dis = 10, pkflag_support = 1, pkflag_punish = 1, maxnum = 50, scheduleConf = {}, scriptId = {}, ... } ... } return mapsetting 如果配置文件中的数据过大，或着是有冗余的无用数据，那么势必会导致输出的Lua文件过大，这将严重影响加载速度和增大内存占用量。本文将介绍一种消除配置文件中冗余数据，达到压缩和优化数据存储的方法。 ◆◆◆ 优化思路 介绍该方法前，大家先来看看一般数据在哪些地方会有冗余。由上面的示例图我们可以清晰地看到数据的冗余点： 大量的数据是重复的，或着是代表没有意义的空值（比如0，[]等）； 大量的中文字符串是需要游戏后期做本地化处理的； 很多复合型数据（子表，数组）内容是一样的； 搞清楚了数据冗余的原因，我们就可以制定优化方案： 对于Excel中的一列，出现次数最多的值认定为默认值，然后把它从Lua表中剔除掉，然后利用metatable机制实现全局默认值存储； 对于中文字符串，替换为一个唯一的id标识，写回到Lua表中，读取的时候加上相应的查找替换； 对于一些复杂的子表或着数组，做唯一化替换处理，替换后写回到原始数据中； 能看出来，上面的操作其实做的都是唯一化处理，所以有个要求就是整个Lua表的数据必须是只读的，如果不满足以上条件，一切优化都是错误的。所以在最后我们需要把整个表改为只读，以保护数据不被错误篡改。 优化后变成了下面这样： local __rt_1 = { 0 } local __rt_2 = { } local __rt_3 = { 10004 } ... local __rt_34 = { desc = "@40313", drop_show = __rt_33, scriptId = { 10001, 10011 }, shield = 6, show_name = "@470882", subtype = 4 } ... --超出Lua局部变量个数，则需要另外单独存储和引用 --createtable是把c中创建表的函数导入到lua中，这样通过创建一个预分配 --的表，能有效的避免table re-hash，有效利用内存 --下面创建一个预分配919个数组array-part、0个元素的空hash-part local __rt = createtable and createtable( 919, 0 ) or {} __rt[1] = { 2861, 1, 46, 8 } __rt[2] = { 2861, 1, 55, 12 } __rt[3] = { 2861, 1, 50, 7 } ... local mapsetting = { { name = "map1", scriptId = __rt_2, shield = 0, show_name = "@384651", type = 0, ... }, { desc = "@424100", music = "map2_r", name = "map2", scriptId = { 10002 }, shield = 0, show_name = "@424100", ... }, { bloomtype = 1, scriptId = __rt_2, shield = 0, show_name = "@491800", type = 0, ... }, { cam_yaw = 1, scriptId = __rt_2, shield = 0, show_name = "@499116", type = 0, ... } } ... --大量的默认值存储在这里 local __default_values = { Belongto = 0, Instance_Show = 0, Instance_group = 0, daily_getcount = 9, damage_modify = 1, desc = "@282313", difficult = 0, pkflag_support = 1, scheduleConf = __rt_2, scriptId = __rt_32, ... } ... do local base = { __index = __default_values, --基类，默认值存取 __newindex = function() --禁止写入新的键值 error( "Attempt to modify read-only table" ) end } for k, v in pairs( mapsetting ) do setmetatable( v, base ) end base.__metatable = false --不让外面获取到元表，防止被无意修改 end return mapsetting 看上去可读性没有原始的那么高了，一些被多处引用的子表已经被替换成了一个变量，这些变量是以local的形式存储在作用域的。由于Lua本身的一些限制，一个作用域内能够存放的最大local变量的个数是200个（lparser.c #define MAXVARS 200），所以超过个数限制的多余部分表会被放入一个临时数组中，初始化的时候需要额外的查表，稍微多一些开销，但可以接受。 ◆◆◆ 归纳总结 一般来说配置表文件优化后只有之前不到一半的大小，大量的重复数据被优化掉了，极大地提升了加载时间和内存占用。 前后文件对比如下： 最后说下宿主语言环境中，如果需要读取Lua表中的子表数据，我们可以把该表的pointer拿出来作为键值，存放于查找表中，这样宿主环境中也能读取到一个唯一的数组，节约内存。 本文中使用的配置表优化工具源码已经放在github，需要的朋友可以自取，喜欢的朋友别忘了给个小星星哟：） https://github.com/lujian101/LuaTableOptimizer 这是侑虎科技第183篇原创文章，感谢作者卢建供稿。欢迎转发分享，未经作者授权请勿转载。作者QQ：345005607。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 同时，作者也是U Sparkle活动参与者哦，UWA欢迎更多开发朋友加入U Sparkle开发者计划，这个舞台有你更精彩！