帧同步场景下,float 精度问题尤为突出。解决方案是用定点数替代浮点数。

方案一:自己实现简易定点数

最简单的方案是全程用 long 进行运算,只在需要转换时才做类型转换。基本思路是:数值运算时直接在 long 之间进行;若需要与 float 运算,则先用 Parse 函数将其转换为 long 再参与计算。这种方式比 Fix64 方案速度更快,也更具可读性。

严格来说,逻辑层的输入和运算都应保持 long(即 int64),只有在需要输出其他数据类型时(如 UI 显示、输出到 Unity)才进行转换。

这套简易定点数方案有一个明显缺陷:非常容易溢出。根本原因在于,表示小数部分的数字参与运算时容量有限。float 之所以不会溢出,是因为它采用科学计数法;而这套简易方案并没有引入科学计数法来表示数值。

public struct Decimal
{
    public const long MaxValue = long.MaxValue;
    public const long MinValue = long.MinValue;

    //转换规则,用大精度转换为小精度,比如float就用double来转换
    //对于乘除来说, 他们之间并不能直接相除 long_a*long_a / split_xxx  才是真正的结果
    public const long SPLIT_LONG = 100000;
    public const int SPLIT_INT = 100000;
    public const float SPLIT_FLOAT = 100000.0f;
    public const double SPLIT_DOUBLE = 100000.0;
    public long value;
    public Decimal(Decimal other)
    {
        this.value = other.value;
    }
    public Decimal(long value)
    {
        this.value = value;// 默认构造不认为是定点 需要手动调用parse
    }
    public Decimal(double value)
    {
        this.value = Parse(value);
    }
    public Decimal(float value)
    {
        this.value = Parse(value);
    }
    public Decimal(int value)
    {
        this.value = Parse(value);
    }
    public static implicit operator Decimal(float value)
    {
        return new Decimal(value);
    }
    public static implicit operator Decimal(double value)
    {
        return new Decimal(value);
    }
    public static implicit operator Decimal(int value)
    {
        return new Decimal(value);
    }
    public static implicit operator Decimal(long value)
    {
        return new Decimal(value);
    }
    public static long Parse(float v)
    {
        double tmp = v;
        return (long)(tmp * SPLIT_DOUBLE);
    }
    public static long Parse(int v)
    {
        long tmp = v;
        return (long)(tmp * SPLIT_INT);
    }
    public static long Parse(double v)
    {
        double tmp = v;
        return (long)(tmp * SPLIT_DOUBLE);
    }
    public static double ToDouble(long v)
    {
        return (double)v / SPLIT_DOUBLE;
    }
    public static int ToInt(long v)
    {
        return (int)(v / SPLIT_LONG);
    }
    public static double ToFloat(long v)
    {
        return (float)ToDouble(v);
    }
    public static double ToDouble(Decimal v)
    {
        return (double)v.value / SPLIT_DOUBLE;
    }
    public static int ToInt(Decimal v)
    {
        return (int)(v.value / SPLIT_LONG);
    }
    public static double ToFloat(Decimal v)
    {
        return (float)ToDouble(v);
    }
    public static Decimal operator *(Decimal x, Decimal y)
    {

    }
}

方案二:使用现成的 Fix64 定点数库

另一个选择是使用基于 long 的现成定点数库 Fix64:

https://github.com/jjcat/FixedMath.Net/blob/master/Fix64.cs

Fix64 的核心算法是用末尾 32 位表示小数部分。其 rawvalue 并不直接可读;虽然是 struct、分配在栈上,但运算速度依然相对较慢。可以把它理解为用 long 实现的科学计数法。

long xxxx=0 ;

        const int FRACTIONAL_PLACES = 32;
        const long ONE = 1L << FRACTIONAL_PLACES;
        xxxx += ONE * 5;
        xxxx += ONE * 2;

        Debug.LogError((float)xxxx / ONE + "        " + ONE);

以上代码输出 7。

性能对比测试

以下是对相同数据量运算的简单性能测试:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using FixMath.NET;
using System.Runtime.InteropServices;

public class NewBehaviourScript : MonoBehaviour
{

    // Use this for initialization
    void Start()
    {
        //   this.GetComponent<Animator>().Play("npc_ani_100011_attack_01");
        //  this.GetComponent<Animator>().Play("npc_ani_100011_standby_01");

        long xx = 2;
        decimal bb = 2;
        float ff = 2f;
        Fix64 fx = (Fix64)2f;
        long dd = 2 * Decimal.SPLIT_LONG;
        System.Diagnostics.Stopwatch aa = new System.Diagnostics.Stopwatch();
        aa.Stop();
        aa.Reset();
        aa.Start();
        for (int i = 0; i < 999999; i++)
        {
            xx *= 2;
            xx /= 2;
        }
        Debug.LogError("long time=" + aa.ElapsedMilliseconds + "   " + xx);
        aa.Stop();
        aa.Reset();
        aa.Start();
        for (int i = 0; i < 999999; i++)
        {
            bb *= 2;
            bb /= 2;
        }
        Debug.LogError("decimal time=" + aa.ElapsedMilliseconds);

        aa.Stop();
        aa.Reset();
        aa.Start();
        for (int i = 0; i < 999999; i++)
        {
            ff *= 2f;
            ff /= 2f;
        }
        Debug.LogError("float time=" + aa.ElapsedMilliseconds + "   " + ff);
        aa.Stop();
        aa.Reset();
        aa.Start();
        for (int i = 0; i < 999999; i++)
        {
            fx = Fix64.FastMul((Fix64)2f, fx);
            fx /= (Fix64)2f;
        }
        Debug.LogError("fix64 with float time=" + aa.ElapsedMilliseconds);

        Fix64 twof = (Fix64)2f;
        aa.Stop();
        aa.Reset();
        aa.Start();
        for (int i = 0; i < 999999; i++)
        {
            fx = Fix64.FastMul(twof, fx);
            fx /= twof;
        }
        Debug.LogError("fix64 time=" + aa.ElapsedMilliseconds);


        aa.Stop();
        aa.Reset();
        aa.Start();
        for (int i = 0; i < 999999; i++)
        {
            dd *= Decimal.Parse(2f);
            dd /= Decimal.Parse(2f);
        }
        Debug.LogError("light decimal time=" + aa.ElapsedMilliseconds + "   " + Decimal.ToDouble(dd));

        /*  long xxxx=0 ;

          const int FRACTIONAL_PLACES = 16;
          const long ONE = 1L << FRACTIONAL_PLACES;
          xxxx += ONE * 5;
          xxxx += ONE * 2;

          Debug.LogError((float)xxxx / ONE + "        " + ONE  + "   " + ONE*ONE/ONE);*/
    }

    // Update is called once per frame
    void Update()
    {

    }
}

从测试结果可以看出,Fix64 的运算速度并不快。