找回密码
 立即注册
查看: 439|回复: 0

Unity AnimationClip极限压缩

[复制链接]
发表于 2020-11-24 07:58 | 显示全部楼层 |阅读模式
引言
角色动画一直是手机游戏必不可少的资源。随着手机游戏品质的提高,动画也越来越丰富、细腻;但与此同时,动画占用包的比重也越来越大。对于包大小敏感的手机游戏来说,压缩动画是在所难免的。本方案通过重新定义Unity动画格式,剔除不必要的曲线数据,以此极限压缩动画,最终打包后(.apk zip后统计)的动画数据压缩到Unity AnimationClip格式存储的1/6。
一、Animation Clip(.clip)文件内容

Clip文件主要存储的是曲线数据:
AnimationClip主要存储的是Curve集合。每个Curve存储着单个对象属性(比如position.x)的运动参数,这些参数主要使用关键帧来描述,每个关键帧存储着时间time、当前值value、左切线inSlope/inTangent、右切线outSlope/outTangent。关键帧结构如下:
由此可见,压缩的关键在于如何压缩关键帧的数据。
二、关键帧压缩

上一节说关键帧存储着时间、值和切线信息等。仔细研究后发现以下特点:
        1)大部分动画切线是Auto模式,也就是说,可以在运行时根据前后帧运算出来;
        2)value使用float存储非常多余,曲线描述的是值变化,绝大部分情况下变化非常小,精度有少量损失也感觉不出来;
        3)时间使用float非常浪费,AnimationClip一般是固定采样率(30/60), 关键帧通常在某个采样点里,0.03333/0.0666/0.999...。
        发现数据特点,就可以在少量效果损失的情况下尽量压缩数据量。主要使用下面的数据结构来存储压缩后的动画数据:
        1)切线处理:存储时丢弃,在初始化的时候使用SmoothTangents计算;
        2)关键帧值(value)处理:考虑到曲线上的值都是相对连续的,可分辨差值一般比较小,但是不同的曲线值的空间会相差巨大(比如rotation.x和position.x),所以这里使用的方法是:
        每条曲线(Curve)存储两个参考值:最大值(valueMax)和最小值(valueMin)组成参考空间,将关键帧值编码成参考空间内的值,编码代码如下:

float valueNormal = Mathf.InverseLerp(valueMin, valueMax, value);
其中valueNormal就是被转换到参考空间内归一化的值。考虑到曲线的特性:可分辨差值一般比较小(比如对于旋转来说,有1-2度的误差感觉不太出来),用一个字节存储参考空间内的值就可以满足需求,所以再将valueNormal转换成byte表示即可。
byte valueByte = (byte)(valueNormal * byte<span class="n">MaxValue);
通过上述转换,可以将float序列编码成byte序列,而且只用了之前1/4的存储空间。还有很重要的一点是,被压缩后值的精度取决于曲线数据的变化范围(range/255),不受绝对值影响,是动态的。对于一般的position和scale是完全够用的。rotation在大部分情况下也是不太明显的。
3)关键帧时间处理:Curve的关键帧的时间点只会在固定的采样点上,比如对于采样率是30/s的动画来说,时间值只会是1/30的倍数(比如0.03333,0.0666,1.0)。这样就可以按bit序列来映射采样点序列是否存在关键帧。



KeyFrame模式:


bit序列模式:


这样就可以将5个关键帧时间压缩到1个byte里去了,在关键帧密集的情况下压缩率将非常高(极限1/32),
注:在帧异常稀疏的时候(大于32个采样点一个关键帧)该方法将需要更大的存储空间。此时可以考虑使用其他的模式,但是这种情况极其少见,而且很容易被zip时字典化压缩掉。
三、存储方式

使用Unity ScriptableObject机制,自动将自定义结构序列化到文件里。结构如下:
    /// 动画(AnimationClip)压缩数据集合
    /// 主要通过压缩曲线数据来进行动画压缩。
    /// *AnimCompression动画压缩是有损的,主要损失的是关键帧值精度和丢弃切线信息,其中关键帧时间是无损的
    /// </summary>
    public class CompressedClipData : ScriptableObject
    {
        //共享数据(属性路径)
        public CompressedShareData shareData;
        /// <summary>
        /// 不存储切线的曲线数据(运行时计算)
        /// </summary>
        public List<CurveDataCalcTan> curvesCalcTan = new List<CurveDataCalcTan>();
        /// <summary>
        /// 存储切线的曲线数据
        /// </summary>
        public List<CurveDataStoreTan> curvesStoreTan = new List<CurveDataStoreTan>();

        public byte frameRate;//帧率
        public float length;//长度
    }
    /// <summary>
    /// 共享数据(属性路径)
    /// </summary>
    public class CompressedShareData : ScriptableObject
    {
        [Serializable]
        public class DataField
        {
            public string propertyName;
            public string propertyPath;
            public string propertyType;
        }
        public List<DataField> fields = new List<DataField>();
    }
        /// <summary>
        /// 曲线压缩数据集
        /// CompressedCurve主要存储
        ///     关键帧时间:无损压缩,将时间转换成帧序列位,有关键帧为【1】否则为【0】,最大压缩率为1/32。
        ///         帧不填满的时候可能没有压缩空间甚至更大,帧无法对齐时会有额外损失。
        ///     关键帧的值:有损压缩:关键帧的值,算法是在最大值和最小值之间线性采样成byte
        ///     关键帧切线:丢弃
        /// </summary>
        [Serializable]
    public class CurveDataBasicValues
    {
        //属性ID
        public ushort property;
        /// <summary>
        /// 时间数据,无损压缩,每个byte对应时间轴上的一帧(非关键帧),关键帧则为1,将时间转换成帧序列位,有关键帧为【1】否则为【0】,最大压缩率为1/32。
        /// </summary>
        public byte[] keysTime;
        /// <summary>
        /// 关键帧值数据,每个byte对应关键帧上一个值,在当前曲线最大值和最小值之间线性采样成byte
        /// </summary>
        public byte[] keysValueByte;
        /// <summary>
        /// 当前曲线的最小值
        /// </summary>
        public float valueMin;
        /// <summary>
        /// 当前曲线的最大值
        /// </summary>
        public float valueMax;
    }
    /// <summary>
    /// 不存储切线的曲线数据(运行时计算)
    /// </summary>
    [Serializable]
    public class CurveDataCalcTan : CurveDataBasicValues
    {
        
    }
    /// <summary>
    /// 存储切线的曲线数据
    /// </summary>
    [Serializable]
    public class CurveDataStoreTan : CurveDataBasicValues
    {
        //切线数值
        public float[] keysTans;
    }
编辑时使用工具将需要压缩AnimationClip压缩成CompressedClipData,使用Unity的.asset格式存储:



       运行时将数据解码成Keyframe,通过AnimationClip.SetCurve接口来还原AnimationClip对象:
public partial class CompressedClipData
{
    //运行时解码后的Clip
    AnimationClip clip;
    /// <summary>
    /// 解码或者获取一个已经解码的AnimationClip
    /// </summary>
    /// <returns></returns>
    public AnimationClip getOrCreateClip()
    {
        if (this.clip)
        {
            return this.clip;
        }
        AnimationClip clip = new AnimationClip();
        //只有legacy才支持SetCurve
        clip.legacy = true;
        clip.name = this.name;
        clip.frameRate = frameRate;
        //解析每个曲线
        DecodeCuves(clip, this.curvesCalcTan);
        DecodeCuves(clip, this.curvesStoreTan);
        this.clip = clip;
        return clip;
    }
    /// <summary>
    /// 解码曲线数据
    /// </summary>
    public void DecodeCuves<T>(AnimationClip clip, List<T> curves)
        where T : CurveDataBasicValues
    {
        //解析曲线集合
        for (int i = 0,count = curves.Count; i < count; i++)
        {
            var curveData = curves;
            //解析单条曲线
            var curve = curveData.decodeData(this);
            if (shareData.fields.Count <= curveData.property)
            {
                Debug.LogError(
                    "属性未找到:" + curveData.property
                    + " clip:" + this.name + " :" + i);
                continue;
            }
            //从shareData中还原字段
            var field = shareData.fields[curveData.property];
            //还原到clip里
            clip.SetCurve(field.propertyPath
                ,typeof(GameObject).Assembly.GetType(field.propertyType)
                ,field.propertyName, curve);
        }
    }
}
四、Animator整合

Unity主要使用Animator来控制播放动画。而Animator里的AnimationClip无法在运行时动态设置曲线数据。对于这个问题,可以采用Unity的StateMachineBehaviour机制解决:
1)编辑时将每个State上的Clip,各转换成一份空的Clip和压缩数据,压缩数据由挂在State
上的CompressionAnimatorStateProxy持有;
2)运行时在状态切换时(OnStateEnter/OnStateExit),使用Animation来模拟Animator驱动对象动画。
五、工具链化

为了便于使用,工具以GameObject Prefab为入口,流程如下:
1)遍历当前Prefab的所有Animator组件,找到AnimatorController复制一份以待压缩,再
将Animator引用执行待压缩的AnimatorController;
2)遍历待压缩的AnimatorController内的所有State,引用的AnimationClip压缩成CompressedClipData,并且挂载CompressionAnimatorStateProxy来引用压缩后的数据对象。然后将Clip(angry)替换成没有数据的stub Clip(angry_stub)。
压缩前:
压缩后:
处理后,原来的业务代码可以保持不变(随意切换压缩或者不压缩)。
五、小结

压缩的主要策略还是在保证效果的情况下,尽量去掉不必要的数据,将数据结构优化成合适的目标信息空间模式。
这个方案大幅度地压缩了帧数据,在极限情况下,打包前可以压缩到(8+1)/(4*32)≈1/16,在项目中打包后(.apk zip后统计)的数据:压缩前17M, 压缩后2.6M,压缩率大概为1/6.5。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-24 06:19 , Processed in 0.104489 second(s), 28 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表