《帧同步定点数》定点数原理和无损精度的实现方式

视屏教程地址:https://www.taikr.com/user/192182

定点数的由来

定点数是指计算机中采用的一种数的表示方法。参与运算的数的小数点位置固定不变。
一般用于对精度要求比较高的逻辑/数据计算中。
常用于帧同步游戏当中,用来同步和计算逻辑数据。

什么是定点数

简单来说就是小数点固定的一种数据结构体,可以固定小数点位1位,也可以固定小数点为0为,总之是需要通过一系技术手段来控制小数点的位数,来防止由于小数精度问题带来的数据计算的误差产生的蝴蝶效应。

请添加图片描述

但是在真正使用定点数的情况下,没有人愿意保留小数去做运算的哪怕是一位。基本都是保留0为小数,整个逻辑计算完全使用整形数据进行运算。这样才能将误差将为最小。

定点数的作用

1.定点数的作用就是为了解决由于浮点数的浮点特性导致的在不同的平台产生不同步问题。

2.安全、可靠。可以完全放心的把数据交给它。因为它能做到在不同的多个平台计算结果/同步结果全都一致。

3.能够简单有效、无误差的进行战斗逻辑或者数据运算的模拟。因为是定点数的机制,故在数据源相同的情况下,结果永远都是相同的。

4.定点数并非只能作用与帧同步中,也可作用于状态同步以及其他的工作中。定点数的优点就是高度准确,在无法接收有误差的工作中都可以使用它。

5.快速、高效。定点数的运算速度是要高于浮点数。

定点数的实现方式

1.小数定位法

小数定位法其实就是固定浮点数的小数点,比如保留一位,保留两位,这样是其实最接近真实值的。但一般来说没什么人会去用。表面上看加减运算是没什么问题的,但只要一涉及到复杂的乘除运算,弊端就暴露出来了,因为仍是浮点数的原因,所以这些复杂的运算就会产生更多的小数。这种情况下,虽然说能通过技术手段只保留一位小数,但是还是会牺牲准确率。

2.乘法放大入整法

这种方法就比较简单粗暴,首先确定放大因子,比如 ‘‘1000’’ 那么所有转为定点数的整型、浮点型数值全都乘上放大因子。
1000”,然后在转为整数。之后的计算就是完全的整型数值之间的运算,完全排除掉了浮点数带来的误差。最后在渲染层需要渲染时,在除上放大因子转为浮点数,交由渲染层去渲染。
这种做法大体来看没什么问题 ,但是细节上会出纰漏。因为放大1000倍,如果在细节上处理不到位,是会丢失掉浮点精度的。如果对定点数理解不深的同学在使用这种方法的情况下,会踩进不小的坑中。

3.位移运算入整法(推荐)

位移运算的原理,下面会讲。
先说一下位移运算的优点。
由于位移运算只能于用作于整形数值,所以非常适合用在定点数中。并且由于位移计算是满足2的次幂的运算的,所以位移运算的效率非常高,速度非常快。

在只计算整形数据的前提下,用位移运算来代替乘除运算简直太好不过。

所以位移运算入整法,是定点数最理想的一种实现方式。

位移运算入整法实现的原理就是提前确定好一个放大次幂,比如 10。那在每个非定点数类型的数值转为定点数时,都将该数值<<(左移)放大次幂10,就能得到一个在原值扩大1024倍的一个数值,也就是定点数。

位移运算的原理

位移运算也成为移位运算。二者皆可,以下就简称为位移运算。

位移运算分为 <<(左移) 和 >>(右移) ,箭头朝左,为左移,箭头朝右为右移。 左移 和 右移 就类似于数学运算中的 乘除
运算,左移可以理解为乘法,右移可以理解为除法。 不过位移运算和乘除还是有一些区别的,位移运算是次幂运算,而乘除运算则是简单的相乘或相除。

下面就以 1 这个数值为案例,进行位移和乘除的操作。为大家讲解何为位移运算,何为乘除运算。

请添加图片描述

由上图可看到,位移运算其实非常简单,就相乘于乘 2 4 8 16 32 …

位移1位就就相当于乘于2,位移2位就相当于乘于4,位移10就相当于乘于1024。
其实也就是 2 的次幂,1位就是2的1次幂,10位就是2的10次幂。

Implicit 隐式转换的原理

1.关键字:Implicit
2.搭配C#关键字 operator 进行使用

隐式转换:不会改变原有数据精确度、引发异常,不会发生任何问题的转换方式。由系统自动转换。
Implicit 关键字使用示例:

		//在float值赋值FinInt值时触发
        public static implicit operator FixInt(float v)
        {
            return new FixInt(v);
        }

隐式转换使用示例:

        int intNum = 1;
        //隐式转换
        long longNum = intNum;
        FixInt fixNum = intNum;

隐式转换可以理解为在数据进行赋值时不需要程序去指定类型或干预的转换,或不同类型可以直接通过=号进行赋值的转换。称之为隐式转换。

如果到这里你还是对隐式转换有疑问,我能保证在看了下面的显示转换之后,你一定会明白什么是隐式转换什么是显示转换。

explicit 显示(强制)转换的原理

1.关键字:explicit
2.搭配C#关键字 operator 进行使用

显示转换:显示转换在开发过程中统称之为强制转换,顾名思义,就是我们程序通过强制指定类型去进行转换的,都可以理解为强制转换。强制转换有可能引发异常、精确度丢失及其他问题的转换方式。需要使用手段进行转换操作。

explicit关键字使用示例:

		//在FixInt值通过(float)转换为float值时触发
        public static explicit operator float(FixInt v)
        {
            return v.RawFloat;
        }

强制转换使用示例:

        double dNum = 1.2321412989585;
        //进行显示(强制)转换操作
        float fNum = (float)dNum;
        int intNum = (int)dNum;

由是强制转换使用案例可以看到,强制转换和隐式转换的区别就在于是否通过 (类型) 指定了要转换的目标类型,如果有通过 (类型) 指定类型,则是强制转换,会触发强制转换接口。如果直接通过 = 进行不同类型的赋值,则是隐式转换,会触发隐式转换的接口。

总结:隐式转换就是不需要添加 (type) 括号+类型进行强转的转换。而显示转换又称为强制转换,需要我们添加 (type) 去指定转换的类型的转换。

定点数实现的原理

定点数的实现原理上面也就讲到过三种方式,小数定位式就不说了,一般来说用的人较少。这里就针对 乘法入整移位入整 进行讲解。

移位运算更快

在说到乘法计算和位移运算的区别的时候,有一点是毋庸置疑的,就是位移运算在计算整形数据时,是要比乘除运算快的。

原因如下:

因为移位指令占2个机器周期,而乘除法指令占4个机器周期。
计算机cpu的移位指令一般单周期就能执行完毕,而其他的指令比如乘法或除法指令都是多周期指令,所以节省了运行时间导致效率更高的结果。

移位运算更准
快是一方面,其实在量体较大的情况下,移位运算是比乘法放大倍数更加准确一点,特别是运算大型数据时。(这里以通常的放大1000倍来举例)。
示例代码:

    void Start()
    {
        int a = 1000;

        float value1 = (long)(a * 1000 * 1.0f / 3400);
        Debug.Log($"乘法扩大 value1:" + value1);

        float value2 = (long)((a << 10) * 1.0f / 3400);
        Debug.Log($"移位运算value2:" + value2);

        Debug.Log( "result float:"+ (value1/100));
        Debug.Log(  "result float:" + (value2/100));
        Debug.Log("result int:" + (int)(value1 / 100));
        Debug.Log("result int:" + (int)(value2 / 100));
    }

示例结果:
在这里插入图片描述
如结果图可见,乘法放大1000倍和位移10位得到的结果是不同的。特别是在经过强转过后,能够很明显的看到,结果完全差了一个数值。这种问题在战斗系统中是非常致命的。
到了这里有人可能就会发现了一点细节问题,说你这计算根本就不准,一个放大1000倍数,一个放大1024倍。中间肯定是有误差的。
这话确实没毛病,确实是有误差的。如果我们使用乘法放大的是1024倍。那么肯定就不会除问题。
所以,综合来说还是放大1000倍,会有问题。其实不管放大多少倍,在我们正常的理解中,2.94转为整形,正确值就应该是3。那这又是为什么转换之后得到的结果却是2呢?

为什么会出现这个问题呢?

这就要从C# int数值的 explicit显示转换 机制说起了,因为Int数值的显示转换是向下取整的。所以我们的2.94数值转换为Int数值之后,结果就会变成2。 这显然不是我们想要的结果。
当然如果这个时候我们使用的放大倍数是1024就不会存在浮点数偏差的问题了。
不过这种方法也可以避免掉,就是在转为Int数值的时候通过 Mathf.Round() 四舍五入接口进行转为Int型,就不会出现数据偏差。但是如果不想数据出现任何偏差,就使用1024作为基准单位,进行放大。

总结:理论上是与1024比较接近,因为我们都知道,1kb=1024字节,为什么1kb不是1000字节呢?而int数值占4个字节。256个int数值就是1024个字节。1024是2的次幂,256也是2的次幂。而计算机不管是移位运算、还是Unity图形压缩格式规范,几乎都是2的次幂有巨大优势。 所以我猜测,计算积底层的计算仍是基于幂运算处理的,只是最小影响化、简略化的给我们转成了以1为单位的数值,因为1.024不管是记起来还是算起来都不怎么方便,也就是舍去了部分精度,得到了最大的便利化。所以,我们只要保证数据放大的基数是进行幂运算,计算机就能保证数据完全准确、速度更快,我们的定点数也就更加准确。

定点数使用注意事项

1.把定点数转换为浮点数后,该浮点数就不应参与帧同步中的任何计算。或者说,帧同步的计算中不需要转换为浮点数,只有把结果交给UI表现时,才需要转为浮点数。

定点数完整代码

using System;

namespace FixMath
{
    //strut 值类型数据结构 值类型的实例在栈上分配内存 :一般单纯的用作储存各种数据类型的相关的结构        (栈内存:由系统自行分配、释放。速度较快。一般存储临时\小型数据)
    //class 引用类型  引用类型的实例在堆上分配内存 :         一般用来处理游戏各种逻辑、数据,用处比较广       (堆内存:Unity中又称托管堆。堆中内存由程序通过New分配内存,由程序释放内存,若程序不去释放,则有系统通过GC去释放,速度较慢,但使用方便。一般储存中大型数据)

    public struct FixInt : IEquatable<FixInt>, IComparable<FixInt>
    {
        public const long MinValue = -9223372036854775808L;
        public const long MaxValue = 9223372036854775807L;

        private const int SHIFT = 10; //位移运算相较于乘除运算速度更快、效率更高。 这里使用扩大1024倍数的方式是因为1024扩大之后的数值计算中更加准确,而1000倍则在数值过大或者精度较高的情况下会损失精度,造成误差。

        public static readonly FixInt One = new FixInt(1);
        public static readonly FixInt Zero = new FixInt();

        private readonly long value;

        public long Value { get { return value; } }
        public int IntValue { get { return (int)value; } }

        public int RawInt { get { return (int)Math.Round((double)(value >> SHIFT)); } }
        public long RawLong { get { return value >> SHIFT; } }
        public float RawFloat { get { return (float)Math.Round(value / 1024.0f * 100)  / 100; } }//精度为2位的小数
        public double RawDouble { get { return value / 1024.0d; } }

        public float SinCosFloat { get { return value*1.0f / 10000.0f; } }

        public FixInt(float value)
        {
            this.value = (long)Math.Round(value) << SHIFT;
        }
        public FixInt(double value)
        {
            this.value = (long)Math.Round(value) << SHIFT;
        }
        public FixInt(int value)
        {
            this.value = value << SHIFT;
        }
        public FixInt(long value)
        {
            this.value = value;
        }


        //强制转换

        //implicit(隐式类型转换运算符)  转换目标类型一般是自定义类型 如 int float double 

        public static implicit operator FixInt(float v)
        {
            return new FixInt(v);
        }
        public static implicit operator FixInt(double v)
        {
            return new FixInt(v);
        }
        public static implicit operator FixInt(int v)
        {
            return new FixInt(v);
        }
        public static implicit operator FixInt(long v)
        {
            return new FixInt(v);
        }

        //explicit(显示类型转换运算符)  转换目标类型一般是自定义类型 如 Fxint
        public static explicit operator float(FixInt v)
        {
            return v.RawFloat;
        }
        public static explicit operator double(FixInt v)
        {
            return v.RawFloat;
        }
        public static explicit operator int(FixInt v)
        {
            return v.RawInt;
        }
        public static explicit operator long(FixInt v)
        {
            return v.RawLong;
        }

        //隐式转换与显示转换的区别就是  显示转换需要加(int) (long) 等类似的转换符号,而隐式转换则可以直接通过=进行赋值。转换的过程被隐藏了起来,故称之为隐式转换。


        //operator 重载预定义C#运算符

        //  + - * /  += -= >= <= >> <<
        public static FixInt operator +(FixInt f1, FixInt f2)
        {
            return new FixInt(f1.value + f2.value);
        }
        public static FixInt operator +(int f1, FixInt f2)
        {
            return (FixInt)f1 + f2;
        }
        public static FixInt operator +(FixInt f1, int f2)
        {
            return f1 + (FixInt)f2;
        }
        public static FixInt operator +(float f1, FixInt f2)
        {
            return (FixInt)f1 + f2;
        }
        public static FixInt operator +(FixInt f1, float f2)
        {
            return f1 + (FixInt)f2;
        }
        public static FixInt operator +(double f1, FixInt f2)
        {
            return (FixInt)f1 + f2;
        }
        public static FixInt operator +(FixInt f1, double f2)
        {
            return f1 + (FixInt)f2;
        }
        public static FixInt operator +(long f1, FixInt f2)
        {
            return (FixInt)f1 + f2;
        }
        public static FixInt operator +(FixInt f1, long f2)
        {
            return f1 + (FixInt)f2;
        }


        public static FixInt operator -(FixInt f1, FixInt f2)
        {
            return new FixInt(f1.value - f2.value);
        }
        public static FixInt operator -(int f1, FixInt f2)
        {
            return (FixInt)f1 - f2;
        }
        public static FixInt operator -(FixInt f1, int f2)
        {
            return f1 - (FixInt)f2;
        }
        public static FixInt operator -(float f1, FixInt f2)
        {
            return (FixInt)f1 - f2;
        }
        public static FixInt operator -(FixInt f1, float f2)
        {
            return f1 - (FixInt)f2;
        }
        public static FixInt operator -(double f1, FixInt f2)
        {
            return (FixInt)f1 - f2;
        }
        public static FixInt operator -(FixInt f1, double f2)
        {
            return f1 - (FixInt)f2;
        }
        public static FixInt operator -(long f1, FixInt f2)
        {
            return (FixInt)f1 - f2;
        }
        public static FixInt operator -(FixInt f1, long f2)
        {
            return f1 - (FixInt)f2;
        }

        public static FixInt operator *(FixInt f1, FixInt f2)
        {
            return new FixInt((f1.value * f2.value) >> SHIFT);
        }
        public static FixInt operator *(int f1, FixInt f2)
        {
            return (FixInt)f1 * f2;
        }
        public static FixInt operator *(FixInt f1, int f2)
        {
            return f1 * (FixInt)f2;
        }
        public static FixInt operator *(float f1, FixInt f2)
        {
            return (FixInt)f1 * f2;
        }
        public static FixInt operator *(FixInt f1, float f2)
        {
            return f1 * (FixInt)f2;
        }
        public static FixInt operator *(double f1, FixInt f2)
        {
            return (FixInt)f1 * f2;
        }
        public static FixInt operator *(FixInt f1, double f2)
        {
            return f1 * (FixInt)f2;
        }
        public static FixInt operator *(long f1, FixInt f2)
        {
            return (FixInt)f1 * f2;
        }
        public static FixInt operator *(FixInt f1, long f2)
        {
            return f1 * (FixInt)f2;
        }

        public static FixInt operator /(FixInt f1, FixInt f2)
        {
           // Console.WriteLine("(f1.value << SHIFT) / f2.value:" + (f1.value << SHIFT) / f2.value);
            FixInt a = (f1.value << SHIFT);
            FixInt b = a.value / f2.value ;  
            return b;//两个long类型相除结果是 long类型的Fixint 因long类型FixInt构造函数不会进行移位操作,故分母进行移位操作后在除
        }
        public static FixInt operator /(int f1, FixInt f2)
        {
            return (FixInt)f1 / f2;
        }
        public static FixInt operator /(FixInt f1, int f2)
        {
            return f1 / (FixInt)f2;
        }
        public static FixInt operator /(float f1, FixInt f2)
        {
            return (FixInt)f1 / f2;
        }
        public static FixInt operator /(FixInt f1, float f2)
        {
            return f1 / (FixInt)f2;
        }
        public static FixInt operator /(double f1, FixInt f2)
        {
            return (FixInt)f1 / f2;
        }
        public static FixInt operator /(FixInt f1, double f2)
        {
            return f1 / (FixInt)f2;
        }
        public static FixInt operator /(long f1, FixInt f2)
        {
            return (FixInt)f1 / f2;
        }
        public static FixInt operator /(FixInt f1, long f2)
        {
            return f1 / (FixInt)f2;
        }

        public static bool operator >(FixInt f1, FixInt f2)
        {
            return f1.value > f2.value;
        }
        public static bool operator >(FixInt f1, int f2)
        {
            return f1 > (FixInt)f2;
        }
        public static bool operator >(int f1, FixInt f2)
        {
            return (FixInt)f1 > f2;
        }
        public static bool operator >(FixInt f1, float f2)
        {
            return f1 > (FixInt)f2;
        }
        public static bool operator >(float f1, FixInt f2)
        {
            return (FixInt)f1 > f2;
        }
        public static bool operator >(FixInt f1, double f2)
        {
            return f1 > (FixInt)f2;
        }
        public static bool operator >(double f1, FixInt f2)
        {
            return (FixInt)f1 > f2;
        }
        public static bool operator >(FixInt f1, long f2)
        {
            return f1 > (FixInt)f2;
        }
        public static bool operator >(long f1, FixInt f2)
        {
            return (FixInt)f1 > f2;
        }

        public static bool operator <(FixInt f1, FixInt f2)
        {
            return f1.value < f2.value;
        }
        public static bool operator <(FixInt f1, int f2)
        {
            return f1 < (FixInt)f2;
        }
        public static bool operator <(int f1, FixInt f2)
        {
            return (FixInt)f1 < f2;
        }
        public static bool operator <(FixInt f1, float f2)
        {
            return f1 < (FixInt)f2;
        }
        public static bool operator <(float f1, FixInt f2)
        {
            return (FixInt)f1 < f2;
        }
        public static bool operator <(FixInt f1, double f2)
        {
            return f1 < (FixInt)f2;
        }
        public static bool operator <(double f1, FixInt f2)
        {
            return (FixInt)f1 < f2;
        }
        public static bool operator <(FixInt f1, long f2)
        {
            return f1 < (FixInt)f2;
        }
        public static bool operator <(long f1, FixInt f2)
        {
            return (FixInt)f1 < f2;
        }

        public static bool operator >=(FixInt f1, FixInt f2)
        {
            return f1.value >= f2.value;
        }
        public static bool operator >=(FixInt f1, int f2)
        {
            return f1 >= (FixInt)f2;
        }
        public static bool operator >=(int f1, FixInt f2)
        {
            return (FixInt)f1 >= f2;
        }
        public static bool operator >=(FixInt f1, float f2)
        {
            return f1 >= (FixInt)f2;
        }
        public static bool operator >=(float f1, FixInt f2)
        {
            return (FixInt)f1 >= f2;
        }
        public static bool operator >=(FixInt f1, double f2)
        {
            return f1 >= (FixInt)f2;
        }
        public static bool operator >=(double f1, FixInt f2)
        {
            return (FixInt)f1 >= f2;
        }
        public static bool operator >=(FixInt f1, long f2)
        {
            return f1 >= (FixInt)f2;
        }
        public static bool operator >=(long f1, FixInt f2)
        {
            return (FixInt)f1 >= f2;
        }

        public static bool operator <=(FixInt f1, FixInt f2)
        {
            return f1.value <= f2.value;
        }
        public static bool operator <=(FixInt f1, int f2)
        {
            return f1 <= (FixInt)f2;
        }
        public static bool operator <=(int f1, FixInt f2)
        {
            return (FixInt)f1 <= f2;
        }
        public static bool operator <=(FixInt f1, float f2)
        {
            return f1 <= (FixInt)f2;
        }
        public static bool operator <=(float f1, FixInt f2)
        {
            return (FixInt)f1 <= f2;
        }
        public static bool operator <=(FixInt f1, double f2)
        {
            return f1 <= (FixInt)f2;
        }
        public static bool operator <=(double f1, FixInt f2)
        {
            return (FixInt)f1 <= f2;
        }
        public static bool operator <=(FixInt f1, long f2)
        {
            return f1 <= (FixInt)f2;
        }
        public static bool operator <=(long f1, FixInt f2)
        {
            return (FixInt)f1 <= f2;
        }

        public static bool operator !=(FixInt f1, FixInt f2)
        {
            return f1.value != f2.value;
        }
        public static bool operator !=(FixInt f1, int f2)
        {
            return f1 != (FixInt)f2;
        }
        public static bool operator !=(int f1, FixInt f2)
        {
            return (FixInt)f1 != f2;
        }
        public static bool operator !=(FixInt f1, float f2)
        {
            return f1 != (FixInt)f2;
        }
        public static bool operator !=(float f1, FixInt f2)
        {
            return (FixInt)f1 != f2;
        }
        public static bool operator !=(FixInt f1, double f2)
        {
            return f1 != (FixInt)f2;
        }
        public static bool operator !=(double f1, FixInt f2)
        {
            return (FixInt)f1 != f2;
        }
        public static bool operator !=(FixInt f1, long f2)
        {
            return f1 != (FixInt)f2;
        }
        public static bool operator !=(long f1, FixInt f2)
        {
            return (FixInt)f1 != f2;
        }

        public static bool operator ==(FixInt f1, FixInt f2)
        {
            return f1.value == f2.value;
        }
        public static bool operator ==(FixInt f1, int f2)
        {
            return f1 != (FixInt)f2;
        }
        public static bool operator ==(int f1, FixInt f2)
        {
            return (FixInt)f1 == f2;
        }
        public static bool operator ==(FixInt f1, float f2)
        {
            return f1 == (FixInt)f2;
        }
        public static bool operator ==(float f1, FixInt f2)
        {
            return (FixInt)f1 == f2;
        }
        public static bool operator ==(FixInt f1, double f2)
        {
            return f1 == (FixInt)f2;
        }
        public static bool operator ==(double f1, FixInt f2)
        {
            return (FixInt)f1 == f2;
        }
        public static bool operator ==(FixInt f1, long f2)
        {
            return f1 == (FixInt)f2;
        }
        public static bool operator ==(long f1, FixInt f2)
        {
            return (FixInt)f1 == f2;
        }

        public static FixInt operator >>(FixInt f1, int count)
        {
            return new FixInt(f1.value >> count);
        }
        public static FixInt operator <<(FixInt f1, int count)
        {
            return new FixInt(f1.value << count);
        }

        public static FixInt operator %(FixInt f1, FixInt f2)
        {
            return new FixInt(f1.value % f2.value);
        }
        public static FixInt operator -(FixInt f1)
        {
            return new FixInt(-f1.value);
        }



        public bool Equals(FixInt other)
        {
            return value == other.value;
        }
        public override bool Equals(object obj)
        {
            return value == ((FixInt)obj).value;
        }
        public override int GetHashCode()
        {
            return value.GetHashCode();
        }
        public override string ToString()
        {
            return RawLong.ToString();
        }
        public string ToStringFloat()
        {
            return RawFloat.ToString("F2");
        }

        /// <summary>
        /// 将当前实例与另一个对象进行比较,该整数表示当前实例的值是大于另一个实例的值还是小于另一个实例的值。
        /// </summary>
        /// <param name="other"></param>
        /// <returns> 小于0 则当前实例小于value  等于0 则当前实例与value相等 大于0则当前实例大于value</returns>
        public int CompareTo(FixInt other)
        {
            return value.CompareTo(other.Value);
        }
    }
}

  • 6
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铸梦xy

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值