为什么将0.1f更改为0会使性能降低10倍?

本文探讨了在使用Visual Studio 2010 SP1编译时,0.1f与0在代码中导致性能下降10倍的原因。关键在于浮点数的非规范化表示,当数值接近0时,处理非规范化浮点数会显著降低性能。解决方案包括禁用异常数值或使用特定编译器选项来优化。

本文翻译自:Why does changing 0.1f to 0 slow down performance by 10x?

Why does this bit of code, 为什么这段代码,

const float x[16] = {  1.1,   1.2,   1.3,     1.4,   1.5,   1.6,   1.7,   1.8,
                       1.9,   2.0,   2.1,     2.2,   2.3,   2.4,   2.5,   2.6};
const float z[16] = {1.123, 1.234, 1.345, 156.467, 1.578, 1.689, 1.790, 1.812,
                     1.923, 2.034, 2.145,   2.256, 2.367, 2.478, 2.589, 2.690};
float y[16];
for (int i = 0; i < 16; i++)
{
    y[i] = x[i];
}

for (int j = 0; j < 9000000; j++)
{
    for (int i = 0; i < 16; i++)
    {
        y[i] *= x[i];
        y[i] /= z[i];
        y[i] = y[i] + 0.1f; // <--
        y[i] = y[i] - 0.1f; // <--
    }
}

run more than 10 times faster than the following bit (identical except where noted)? 比下面的位快10倍以上(相同的地方,除非特别说明)?

const float x[16] = {  1.1,   1.2,   1.3,     1.4,   1.5,   1.6,   1.7,   1.8,
                       1.9,   2.0,   2.1,     2.2,   2.3,   2.4,   2.5,   2.6};
const float z[16] = {1.123, 1.234, 1.345, 156.467, 1.578, 1.689, 1.790, 1.812,
                     1.923, 2.034, 2.145,   2.256, 2.367, 2.478, 2.589, 2.690};
float y[16];
for (int i = 0; i < 16; i++)
{
    y[i] = x[i];
}

for (int j = 0; j < 9000000; j++)
{
    for (int i = 0; i < 16; i++)
    {
        y[i] *= x[i];
        y[i] /= z[i];
        y[i] = y[i] + 0; // <--
        y[i] = y[i] - 0; // <--
    }
}

when compiling with Visual Studio 2010 SP1. 使用Visual Studio 2010 SP1进行编译时。 The optimization level was -02 with sse2 enabled. 启用sse2的优化级别为-02 I haven't tested with other compilers. 我没有与其他编译器一起测试过。


#1楼

参考:https://stackoom.com/question/d58Q/为什么将-f更改为-会使性能降低-倍


#2楼

In gcc you can enable FTZ and DAZ with this: 在gcc中,您可以通过以下方式启用FTZ和DAZ:

#include <xmmintrin.h>

#define FTZ 1
#define DAZ 1   

void enableFtzDaz()
{
    int mxcsr = _mm_getcsr ();

    if (FTZ) {
            mxcsr |= (1<<15) | (1<<11);
    }

    if (DAZ) {
            mxcsr |= (1<<6);
    }

    _mm_setcsr (mxcsr);
}

also use gcc switches: -msse -mfpmath=sse 也使用gcc开关:-msse -mfpmath = sse

(corresponding credits to Carl Hetherington [1]) (相当于学分卡尔·赫瑟灵顿[1])

[1] http://carlh.net/plugins/denormals.php [1] http://carlh.net/plugins/denormals.php


#3楼

It's due to denormalized floating-point use. 这是由于使用了非规范化的浮点数。 How to get rid of both it and the performance penalty? 如何摆脱它和性能损失? Having scoured the Internet for ways of killing denormal numbers, it seems there is no "best" way to do this yet. 搜寻Internet来消除异常数字的方法之后,似乎尚无“最佳”方法。 I have found these three methods that may work best in different environments: 我发现这三种方法可能在不同的环境中效果最好:

  • Might not work in some GCC environments: 在某些GCC环境中可能无法使用:

     // Requires #include <fenv.h> fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV); 
  • Might not work in some Visual Studio environments: 1 在某些Visual Studio环境中可能不起作用: 1

     // Requires #include <xmmintrin.h> _mm_setcsr( _mm_getcsr() | (1<<15) | (1<<6) ); // Does both FTZ and DAZ bits. You can also use just hex value 0x8040 to do both. // You might also want to use the underflow mask (1<<11) 
  • Appears to work in both GCC and Visual Studio: 似乎可以在GCC和Visual Studio中使用:

     // Requires #include <xmmintrin.h> // Requires #include <pmmintrin.h> _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON); 
  • The Intel compiler has options to disable denormals by default on modern Intel CPUs. 英特尔编译器具有在现代英特尔CPU上默认情况下禁用反常态的选项。 More details here 在这里更多细节

  • Compiler switches. 编译器开关。 -ffast-math , -msse or -mfpmath=sse will disable denormals and make a few other things faster, but unfortunately also do lots of other approximations that might break your code. -ffast-math-msse-mfpmath=sse将禁用异常,并使其他一些事情更快,但不幸的是,它还会执行许多其他近似操作,可能会破坏您的代码。 Test carefully! 仔细测试! The equivalent of fast-math for the Visual Studio compiler is /fp:fast but I haven't been able to confirm whether this also disables denormals. 对于Visual Studio编译器来说,快速运算的等效项是/fp:fast但是我无法确认这是否也禁用了异常。 1 1个


#4楼

Dan Neely's comment ought to be expanded into an answer: 丹·尼利的评论应扩展为一个答案:

It is not the zero constant 0.0f that is denormalized or causes a slow down, it is the values that approach zero each iteration of the loop. 0.0f化或导致减慢的不是零常数0.0f ,而是每次循环迭代时接近零的值。 As they come closer and closer to zero, they need more precision to represent and they become denormalized. 随着它们越来越接近于零,它们需要更高的精度来表示,并且它们变得规范化了。 These are the y[i] values. 这些是y[i]值。 (They approach zero because x[i]/z[i] is less than 1.0 for all i .) (它们接近零,因为所有i x[i]/z[i]都小于1.0。)

The crucial difference between the slow and fast versions of the code is the statement y[i] = y[i] + 0.1f; 代码的慢速版本和快速版本之间的关键区别是语句y[i] = y[i] + 0.1f; . As soon as this line is executed each iteration of the loop, the extra precision in the float is lost, and the denormalization needed to represent that precision is no longer needed. 在循环的每次迭代中执行此行后,浮点数中的额外精度就会丢失,并且不再需要代表该精度的非规范化。 Afterwards, floating point operations on y[i] remain fast because they aren't denormalized. 之后, y[i]上的浮点运算将保持快速状态,因为它们没有被非规格化。

Why is the extra precision lost when you add 0.1f ? 为什么添加0.1f失去额外的精度? Because floating point numbers only have so many significant digits. 因为浮点数只有很多有效数字。 Say you have enough storage for three significant digits, then 0.00001 = 1e-5 , and 0.00001 + 0.1 = 0.1 , at least for this example float format, because it doesn't have room to store the least significant bit in 0.10001 . 假设您有足够的存储空间来存储三个有效数字,然后至少对于本例float格式而言,则为0.00001 = 1e-50.00001 + 0.1 = 0.1 ,因为它没有空间存储0.10001的最低有效位。

In short, y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; 简而言之, y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; isn't the no-op you might think it is. 可能不是您想的那样。

Mystical said this as well : the content of the floats matters, not just the assembly code. 神秘主义者也这样说 :浮点数的内容很重要,而不仅仅是汇编代码。


#5楼

Using gcc and applying a diff to the generated assembly yields only this difference: 使用gcc并将diff应用于生成的程序集只会产生以下差异:

73c68,69
<   movss   LCPI1_0(%rip), %xmm1
---
>   movabsq $0, %rcx
>   cvtsi2ssq   %rcx, %xmm1
81d76
<   subss   %xmm1, %xmm0

The cvtsi2ssq one being 10 times slower indeed. cvtsi2ssq慢了10倍。

Apparently, the float version uses an XMM register loaded from memory, while the int version converts a real int value 0 to float using the cvtsi2ssq instruction, taking a lot of time. 显然, float版本使用从内存加载的XMM寄存器,而int版本使用cvtsi2ssq指令将实际的int值0转换为float ,这会花费很多时间。 Passing -O3 to gcc doesn't help. -O3传递给gcc并没有帮助。 (gcc version 4.2.1.) (gcc版本4.2.1)。

(Using double instead of float doesn't matter, except that it changes the cvtsi2ssq into a cvtsi2sdq .) (使用double而不是float没关系,只不过它将cvtsi2ssq更改为cvtsi2sdq 。)

Update 更新资料

Some extra tests show that it is not necessarily the cvtsi2ssq instruction. 一些额外的测试表明,它不一定是cvtsi2ssq指令。 Once eliminated (using a int ai=0;float a=ai; and using a instead of 0 ), the speed difference remains. 一旦消除(使用int ai=0;float a=ai;并使用a而不是0 ),则速度差仍然存在。 So @Mysticial is right, the denormalized floats make the difference. 因此,@ Mysticial是正确的,非规范化的浮点数会有所作为。 This can be seen by testing values between 0 and 0.1f . 通过测试00.1f之间的值可以看出这一点。 The turning point in the above code is approximately at 0.00000000000000000000000000000001 , when the loops suddenly takes 10 times as long. 上面的代码中的转折点大约为0.00000000000000000000000000000001 ,这时循环突然花费了10倍的时间。

Update<<1 更新<< 1

A small visualisation of this interesting phenomenon: 关于这个有趣现象的小图:

  • Column 1: a float, divided by 2 for every iteration 第1列:浮点数,每次迭代均除以2
  • Column 2: the binary representation of this float 第2列:此浮点数的二进制表示形式
  • Column 3: the time taken to sum this float 1e7 times 第3列:求和该浮点数所需的时间1e7次

You can clearly see the exponent (the last 9 bits) change to its lowest value, when denormalization sets in. At that point, simple addition becomes 20 times slower. 您可以清楚地看到,在进行非规格化设置时,指数(最后9位)变为最低值。这时,简单加法会慢20倍。

0.000000000000000000000000000000000100000004670110: 10111100001101110010000011100000 45 ms
0.000000000000000000000000000000000050000002335055: 10111100001101110010000101100000 43 ms
0.000000000000000000000000000000000025000001167528: 10111100001101110010000001100000 43 ms
0.000000000000000000000000000000000012500000583764: 10111100001101110010000110100000 42 ms
0.000000000000000000000000000000000006250000291882: 10111100001101110010000010100000 48 ms
0.000000000000000000000000000000000003125000145941: 10111100001101110010000100100000 43 ms
0.000000000000000000000000000000000001562500072970: 10111100001101110010000000100000 42 ms
0.000000000000000000000000000000000000781250036485: 10111100001101110010000111000000 42 ms
0.000000000000000000000000000000000000390625018243: 10111100001101110010000011000000 42 ms
0.000000000000000000000000000000000000195312509121: 10111100001101110010000101000000 43 ms
0.000000000000000000000000000000000000097656254561: 10111100001101110010000001000000 42 ms
0.000000000000000000000000000000000000048828127280: 10111100001101110010000110000000 44 ms
0.000000000000000000000000000000000000024414063640: 10111100001101110010000010000000 42 ms
0.000000000000000000000000000000000000012207031820: 10111100001101110010000100000000 42 ms
0.000000000000000000000000000000000000006103515209: 01111000011011100100001000000000 789 ms
0.000000000000000000000000000000000000003051757605: 11110000110111001000010000000000 788 ms
0.000000000000000000000000000000000000001525879503: 00010001101110010000100000000000 788 ms
0.000000000000000000000000000000000000000762939751: 00100011011100100001000000000000 795 ms
0.000000000000000000000000000000000000000381469876: 01000110111001000010000000000000 896 ms
0.000000000000000000000000000000000000000190734938: 10001101110010000100000000000000 813 ms
0.000000000000000000000000000000000000000095366768: 00011011100100001000000000000000 798 ms
0.000000000000000000000000000000000000000047683384: 00110111001000010000000000000000 791 ms
0.000000000000000000000000000000000000000023841692: 01101110010000100000000000000000 802 ms
0.000000000000000000000000000000000000000011920846: 11011100100001000000000000000000 809 ms
0.000000000000000000000000000000000000000005961124: 01111001000010000000000000000000 795 ms
0.000000000000000000000000000000000000000002980562: 11110010000100000000000000000000 835 ms
0.000000000000000000000000000000000000000001490982: 00010100001000000000000000000000 864 ms
0.000000000000000000000000000000000000000000745491: 00101000010000000000000000000000 915 ms
0.000000000000000000000000000000000000000000372745: 01010000100000000000000000000000 918 ms
0.000000000000000000000000000000000000000000186373: 10100001000000000000000000000000 881 ms
0.000000000000000000000000000000000000000000092486: 01000010000000000000000000000000 857 ms
0.000000000000000000000000000000000000000000046243: 10000100000000000000000000000000 861 ms
0.000000000000000000000000000000000000000000022421: 00001000000000000000000000000000 855 ms
0.000000000000000000000000000000000000000000011210: 00010000000000000000000000000000 887 ms
0.000000000000000000000000000000000000000000005605: 00100000000000000000000000000000 799 ms
0.000000000000000000000000000000000000000000002803: 01000000000000000000000000000000 828 ms
0.000000000000000000000000000000000000000000001401: 10000000000000000000000000000000 815 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 42 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 42 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 44 ms

An equivalent discussion about ARM can be found in Stack Overflow question Denormalized floating point in Objective-C? 可以在Stack Overflow问题Objective-C中的非规范化浮点中找到关于ARM的等效讨论 .


#6楼

Welcome to the world of denormalized floating-point ! 欢迎来到非规范化浮点世界! They can wreak havoc on performance!!! 他们会对性能造成严重破坏!!!

Denormal (or subnormal) numbers are kind of a hack to get some extra values very close to zero out of the floating point representation. 非正规(或非正规)数字是一种破解,可以从浮点表示中获得非常接近于零的一些额外值。 Operations on denormalized floating-point can be tens to hundreds of times slower than on normalized floating-point. 在非标准化浮点上的操作可能比在标准化浮点上的操作慢几十到数百倍 This is because many processors can't handle them directly and must trap and resolve them using microcode. 这是因为许多处理器无法直接处理它们,而必须使用微码来捕获和解析它们。

If you print out the numbers after 10,000 iterations, you will see that they have converged to different values depending on whether 0 or 0.1 is used. 如果在10,000次迭代后打印出数字,您将看到它们已经收敛为不同的值,具体取决于使用0还是0.1

Here's the test code compiled on x64: 这是在x64上编译的测试代码:

int main() {

    double start = omp_get_wtime();

    const float x[16]={1.1,1.2,1.3,1.4,1.5,1.6,1.7,1.8,1.9,2.0,2.1,2.2,2.3,2.4,2.5,2.6};
    const float z[16]={1.123,1.234,1.345,156.467,1.578,1.689,1.790,1.812,1.923,2.034,2.145,2.256,2.367,2.478,2.589,2.690};
    float y[16];
    for(int i=0;i<16;i++)
    {
        y[i]=x[i];
    }
    for(int j=0;j<9000000;j++)
    {
        for(int i=0;i<16;i++)
        {
            y[i]*=x[i];
            y[i]/=z[i];
#ifdef FLOATING
            y[i]=y[i]+0.1f;
            y[i]=y[i]-0.1f;
#else
            y[i]=y[i]+0;
            y[i]=y[i]-0;
#endif

            if (j > 10000)
                cout << y[i] << "  ";
        }
        if (j > 10000)
            cout << endl;
    }

    double end = omp_get_wtime();
    cout << end - start << endl;

    system("pause");
    return 0;
}

Output: 输出:

#define FLOATING
1.78814e-007  1.3411e-007  1.04308e-007  0  7.45058e-008  6.70552e-008  6.70552e-008  5.58794e-007  3.05474e-007  2.16067e-007  1.71363e-007  1.49012e-007  1.2666e-007  1.11759e-007  1.04308e-007  1.04308e-007
1.78814e-007  1.3411e-007  1.04308e-007  0  7.45058e-008  6.70552e-008  6.70552e-008  5.58794e-007  3.05474e-007  2.16067e-007  1.71363e-007  1.49012e-007  1.2666e-007  1.11759e-007  1.04308e-007  1.04308e-007

//#define FLOATING
6.30584e-044  3.92364e-044  3.08286e-044  0  1.82169e-044  1.54143e-044  2.10195e-044  2.46842e-029  7.56701e-044  4.06377e-044  3.92364e-044  3.22299e-044  3.08286e-044  2.66247e-044  2.66247e-044  2.24208e-044
6.30584e-044  3.92364e-044  3.08286e-044  0  1.82169e-044  1.54143e-044  2.10195e-044  2.45208e-029  7.56701e-044  4.06377e-044  3.92364e-044  3.22299e-044  3.08286e-044  2.66247e-044  2.66247e-044  2.24208e-044

Note how in the second run the numbers are very close to zero. 请注意,在第二轮中,数字如何非常接近零。

Denormalized numbers are generally rare and thus most processors don't try to handle them efficiently. 非规范化的数字通常很少见,因此大多数处理器都不会尝试有效地处理它们。


To demonstrate that this has everything to do with denormalized numbers, if we flush denormals to zero by adding this to the start of the code: 为了证明这与非规格化数字有关,如果我们通过将非正规数添加到代码的开头将其冲洗为零 ,则可以:

_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);

Then the version with 0 is no longer 10x slower and actually becomes faster. 然后,具有0的版本不再慢10倍,而实际上变得更快。 (This requires that the code be compiled with SSE enabled.) (这要求在启用SSE的情况下编译代码。)

This means that rather than using these weird lower precision almost-zero values, we just round to zero instead. 这意味着我们不使用这些奇怪的较低精度的几乎为零的值,而是舍入为零。

Timings: Core i7 920 @ 3.5 GHz: 时间:Core i7 920 @ 3.5 GHz:

//  Don't flush denormals to zero.
0.1f: 0.564067
0   : 26.7669

//  Flush denormals to zero.
0.1f: 0.587117
0   : 0.341406

In the end, this really has nothing to do with whether it's an integer or floating-point. 最后,这确实与整数或浮点数无关。 The 0 or 0.1f is converted/stored into a register outside of both loops. 00.1f转换/存储到两个循环之外的寄存器中。 So that has no effect on performance. 因此,这对性能没有影响。

<think> 我们正在分析:在PostGameplayEffectExecute函数中,已经使用FMath::Clamp将HP限制在[0, MaxHP]范围内,但HP仍然变为负值的情况。 可能原因分析: 1. 递归调用导致钳位失效:在PostGameplayEffectExecute中调用SetHP会触发新的GameplayEffect事件,形成递归。在递归调用链中,如果中间某一步跳过了钳位逻辑,则最终值可能为负。 2. 多个属性同时修改:当MaxHP和HP同时被修改时,执行顺序可能导致钳位逻辑失效。 例如:假设当前HP=10, MaxHP=20,然后同时应用两个效果:效果A将HP设为-5,效果B将MaxHP设为0。 可能的执行顺序: 先处理效果A:PostGameplayEffectExecute中检测到HP被修改,此时MaxHP还是20,所以钳位到0(因为-5<0,所以钳位后HP=0)。 然后处理效果B:修改MaxHP=0,此时会再次触发PostGameplayEffectExecute(因为MaxHP变化),然后我们会检测到MaxHP变化,于是再次钳位HP:当前HP=0,MaxHP=0,钳位后HP=0。 但是,如果执行顺序反过来: 先处理效果B:将MaxHP设为0,然后触发PostGameplayEffectExecute,此时因为修改的是MaxHP,所以我们会钳位HP:当前HP=10(还未被修改),MaxHP=0,则钳位后HP=0(因为10>0,所以设为0)。 然后处理效果A:将HP设为-5,此时MaxHP=0,钳位:将-5钳位到0。 所以这种情况下也不会出现负数。但是,如果我们没有在MaxHP变化时也钳位HP,那么在效果B执行后,HP还是10(大于MaxHP=0),然后效果A将HP设为-5,此时我们钳位到0,也不会出现负数。 3. 另一种可能性:在同一个GameplayEffect中同时修改HP和MaxHP,并且修改的顺序导致了问题。在同一个GameplayEffect中,属性修改的顺序是不确定的(除非使用自定义的修改器顺序)。所以可能出现这样的情况: 在一个GameplayEffect中,先修改MaxHP(设为0),然后修改HP(设为-5)。在PostGameplayEffectExecute中,我们每次只处理一个属性的修改(即每次回调只针对一个属性)。因此,当处理MaxHP修改时,我们可能会将HP钳位到0(因为此时HP还是旧值,比如10,大于0,所以钳位后变为0)。然后处理HP修改时,将其设为-5,然后再次钳位(由于MaxHP已经是0,所以-5会被钳位到0)。所以也不会出现负数。 4. 因此,我们需要考虑是否在所有的属性修改路径上都正确进行了钳位。例如: - 是否只在HP或MaxHP被修改时才执行钳位?如果只处理了HP修改,而没有处理MaxHP修改时的钳位,那么当MaxHP降低到当前HP以下时,HP不会自动降低,此时如果受到伤害,可能会先减到一个超过MaxHP的值,然后被钳位到MaxHP,但这不会导致负数。但如果我们随后又受到一次伤害,将HP减到负数,然后被钳位到0,也不会出现负数。 所以,关键是要确保在任何可能影响HP或MaxHP的情况下,都执行钳位。 5. 但是,根据问题描述,已经使用了FMath::Clamp限制最小值为0,仍然出现负数。那么我们考虑是否有其他代码路径绕过了这个钳位。 6. 可能性:直接在AttributeSet外部修改属性而没有经过GameplayEffect。例如,直接调用AbilitySystemComponent->SetNumericAttributeBase(GetHPAttribute(), -10)。这种情况下,不会触发PostGameplayEffectExecute,因此不会钳位。 7. 可能性:使用了Instant类型的GameplayEffect,并且其执行顺序导致钳位被覆盖。例如,在同一个GameplayEffect执行堆栈中,多个效果修改同一个属性,而最终的合并值在应用后只触发一次PostGameplayEffectExecute。但是,在PostGameplayEffectExecute中,我们得到的是合并后的值,所以应该能够正确钳位。 8. 网络同步问题:在客户端上,由于预测机制,可能先应用了一个负值,然后服务器修正值尚未到达。但是,在客户端本地也会执行PostGameplayEffectExecute,应该也会钳位。 9. 另一个重要原因:在PreAttributeChange中修改了NewValue,但是修改后的值可能仍然是负数?因为我们知道PreAttributeChange是在应用GameplayEffect之前调用的,可以用来做钳位。但是,如果我们在PreAttributeChange中没有钳位,而只依赖PostGameplayEffectExecute,那么可能会有问题吗? 注意:PreAttributeChange和PostGameplayEffectExecute的区别: PreAttributeChange: 在GameplayEffect修改属性之前被调用,传入的是修改后的值(尚未写入)。在这里可以对NewValue进行修改,修改后的值将作为最终修改值。但是,这里不能进行复杂的逻辑(比如触发其他事件),因为可能在每个修改步骤中多次调用(聚合修改)。 PostGameplayEffectExecute: 在GameplayEffect修改已经应用之后调用(此时属性值已经改变),这里可以进行一些后处理,比如触发事件,修改其他属性等。 因此,如果我们在PreAttributeChange中不进行任何限制,而在PostGameplayEffectExecute中进行钳位,那么: Step1: 一个GameplayEffect试图将HP设为-10。 Step2: PreAttributeChange中,我们不对NewValue做修改,所以HP被设置为-10(临时状态,因为后面还会调用PostGameplayEffectExecute)。 Step3: 进入PostGameplayEffectExecute,我们检测到HP被修改,于是调用SetHP(FMath::Clamp(GetHP(),0,GetMaxHP())),将HP设为0。 所以最终结果还是0。 但是,这里有一个问题:在PostGameplayEffectExecute中调用SetHP会触发一个新的GameplayEffect事件(因为是主动修改属性),这个事件又会触发PreAttributeChange和PostGameplayEffectExecute。这就是递归问题。 10. 因此,我们得出结论:在PostGameplayEffectExecute中调用SetHP来钳位会导致递归,而递归可能会导致不可预知的结果(比如,如果递归过程中又修改了MaxHP,那么可能中间状态被破坏)。所以,我们应该避免在PostGameplayEffectExecute中调用SetHP。 11. 正确的做法:在PreAttributeChange中进行钳位。因为PreAttributeChange是在属性实际改变之前调用的,我们可以直接修改NewValue,这样就不会有递归问题。 示例代码: 在AttributeSet的PreAttributeChange函数中: if (Attribute == GetHPAttribute()) { // 将新的HP值限制在[0, GetMaxHP()]之间 NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxHP()); } else if (Attribute == GetMaxHPAttribute()) { // 当MaxHP变化时,我们可能需要同时钳位当前HP(因为MaxHP变小可能导致当前HP超过MaxHP) // 但是注意:在PreAttributeChange中,我们无法直接修改HP属性(因为Attribute参数是MaxHP),所以不能在这里修改HP属性。 // 因此,我们需要在MaxHP变化时,也要在PostGameplayEffectExecute中处理HP的钳位?或者,我们可以在PreAttributeChange中修改HP属性吗?答案是不可以,因为此时HP属性可能也被修改,我们不能直接改变另一个属性的NewValue。 } 但是,对于MaxHP的修改,我们无法在PreAttributeChange中同时修改HP属性(因为函数参数只提供了当前属性(MaxHP)的NewValue)。所以,对于MaxHP的变化,我们有两种处理方式: a) 在PostGameplayEffectExecute中处理:当MaxHP被修改时,检查并钳位HP。但是,如前所述,在PostGameplayEffectExecute中调用SetHP会导致递归。 b) 在PreAttributeChange中,通过修改器上下文同时修改HP属性?这是不允许的,因为PreAttributeChange一次只处理一个属性。 12. 因此,我们可能需要分开处理: - 对于HP的修改,在PreAttributeChange中钳位。 - 对于MaxHP的修改,在PreAttributeChange中,我们无法钳位HP,所以只能在PostGameplayEffectExecute中处理,但要注意避免递归。 为了避免递归,在PostGameplayEffectExecute中处理MaxHP修改时,我们不应该调用SetHP,而是直接设置底层变量的值(不触发事件)。 但是,直接设置底层变量会绕过属性系统的事件通知,可能导致一些监听器失效。所以,我们需要使用另一种方式:在PostGameplayEffectExecute中,如果检测到MaxHP被修改,那么我们就修改HP的值,但通过AbilitySystemComponent来设置属性(这样可以触发事件),但是要避免再次进入同一个PostGameplayEffectExecute。 13. 解决方案:在AttributeSet中提供一个安全的方法来设置HP而不产生递归。我们可以设置一个标志位,表示当前正在处理钳位,避免重复处理。 但是,简洁的方法是:在PreAttributeChange中处理HP的修改(包括当MaxHP变化后,HP超过MaxHP的情况)?实际上,我们可以在PreAttributeChange中同时检查当前HP和MaxHP的关系吗?但是当MaxHP改变时,我们无法在同一个PreAttributeChange调用中改变HP的NewValue。 14. 因此,推荐的做法是: - 在PreAttributeChange中,对于HP的修改,钳位到[0, 当前MaxHP]。 - 在PreAttributeChange中,对于MaxHP的修改,我们不做任何事(除了确保MaxHP不为负)。 - 然后,在PostGameplayEffectExecute中,当MaxHP被修改后,我们检查HP是否大于MaxHP(或者小于0),如果是,则通过AbilitySystemComponent设置HP属性(这将触发一个新的GameplayEffect事件,从而再次进入PreAttributeChange,在PreAttributeChange中钳位HP)。 注意:这样会导致两次事件,但不会递归,因为第二次事件是在第一次事件完成后触发的。 但是,这样会多一次事件触发,可能会影响性能15. 另一种做法:在同一个PostGameplayEffectExecute中处理MaxHP修改时,直接修改HP属性(使用AbilitySystemComponent->SetNumericAttributeBase),并设置一个标志防止递归处理这个新事件(例如,设置一个作用域的标记,在标记设置时忽略后续事件)。但要注意线程安全。 16. 回到最初的问题:为什么在已经使用FMath::Clamp限制最小值为0的情况下,仍然出现负数? 最可能的原因是:在PostGameplayEffectExecute中通过SetHP进行钳位,导致了递归,而在递归过程中,某一步修改了MaxHP(比如将其设为负数),然后导致后续的钳位失效。 例如: 第一次调用PostGameplayEffectExecute(由于HP被修改为-10): 调用SetHP(FMath::Clamp(-10, 0, GetMaxHP())) -> 假设当前MaxHP为100,那么钳位后为0。 但是,在SetHP(0)的过程中,会触发第二次PostGameplayEffectExecute(因为HP被设置为0)。 在第二次调用中,可能检测到HP被修改(从-10变为0),然后再次调用SetHP进行钳位(00100)=>0,这没有问题。 但是,如果在第一次调用PostGameplayEffectExecute的过程中,有一个回调函数修改了MaxHP(比如将其设置为-10),那么就会有问题: 在第一次调用中,我们执行:SetHP(FMath::Clamp(-10, 0, GetMaxHP())) 此时,GetMaxHP()返回-10,那么钳位:FMath::Clamp(-10, 0, -10) => 结果是-10(因为-100-10之间,取-10)。 这样,HP就被设置成了-10。 所以,问题可能出在MaxHP被设置成了负数。因此,我们必须确保MaxHP不能为负数。 17. 因此,解决方案: - 在MaxHP的修改中也加入钳位:在PreAttributeChange中,对于MaxHP属性,将其最小值设为0(或者一个很小的正数,比如0.1)。 - 同时,在PreAttributeChange中对HP进行钳位([0, MaxHP])。 - 不再依赖PostGameplayEffectExecute中的钳位(避免递归和顺序问题)。 示例代码: void UMyAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) { Super::PreAttributeChange(Attribute, NewValue); if (Attribute == GetHPAttribute()) { // 获取当前MaxHP的值(注意:这里获取的是修改前的MaxHP,因为MaxHP的修改可能还未应用) // 但是,如果同时修改了MaxHP,那么MaxHP的PreAttributeChange可能还未调用,所以这里的GetMaxHP()返回的还是旧值。 // 所以,如果我们在同一个GameplayEffect中同时修改HP和MaxHP,那么这里用旧值MaxHP来钳位HP就不准确。 NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxHP()); } else if (Attribute == GetMaxHPAttribute()) { // 确保MaxHP至少为0,也可以设置为一个最小值(比如1) NewValue = FMath::Max(NewValue, 0.0f); // 注意:此时我们无法同时修改HP,因为当前属性是MaxHP } } 但是,这里有一个问题:如果在同一个GameplayEffect中同时修改HP和MaxHP,那么当HP的PreAttributeChange被调用时,MaxHP的PreAttributeChange可能还未被调用(顺序不确定),所以GetMaxHP()返回的是旧值。如果新的MaxHP比旧值低,那么HP的钳位可能不会立即将HP降到新的MaxHP以下,导致HP超过MaxHP。 为了解决这个问题,我们需要在PostGameplayEffectExecute中处理(当MaxHP被修改后,检查HP是否超过MaxHP)。但是,为了避免递归,我们可以这样做: void UMyAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) { Super::PostGameplayEffectExecute(Data); // 如果修改的是MaxHP,那么检查当前HP是否超过MaxHP,如果是,则通过AbilitySystemComponent设置HP(这将会触发新的事件,在PreAttributeChange中钳位) if (Data.EvaluatedData.Attribute == GetMaxHPAttribute()) { float CurrentHP = GetHP(); float CurrentMaxHP = GetMaxHP(); if (CurrentHP > CurrentMaxHP) { // 触发新的事件,设置HP为CurrentMaxHP if (UAbilitySystemComponent* ASC = GetOwningAbilitySystemComponent()) { ASC->SetNumericAttributeBase(GetHPAttribute(), CurrentMaxHP); } } } // 注意:这里不再处理HP属性的修改,因为已经在PreAttributeChange中钳位了 } 但是,这样处理HP的修改事件: - 在PreAttributeChange中,我们保证了HP不会小于0,也不会超过当时的MaxHP(但当时MaxHP可能是旧值)。 - 在PostGameplayEffectExecute中,当MaxHP被修改后,如果发现HP大于新的MaxHP,我们就设置HP为MaxHP。 这样,HP的修改事件不会在这里处理(除了通过MaxHP的变化间接触发设置HP),所以不会导致递归(因为设置HP的事件是独立的,不会再次进入同一个PostGameplayEffectExecute回调)。 18. 总结: - 在PreAttributeChange中处理HP的钳位(确保在0和当前MaxHP之间)和MaxHP的钳位(最小值0)。 - 在PostGameplayEffectExecute中,当MaxHP被修改后,检查HP是否超过MaxHP,如果是,则通过AbilitySystemComponent设置HP(触发新的事件,这个事件将在PreAttributeChange中被钳位,所以最终HP=MaxHP)。 - 这样就可以避免递归,同时确保任何情况下HP都在[0, MaxHP]范围内。 19. 为什么之前已经钳位了最小值为0,还是会出现负数?可能就是因为递归导致中间状态被破坏(比如在递归过程中修改了MaxHP为负数),或者没有对MaxHP进行钳位(导致MaxHP为负数,从而使HP的钳位无效)。 因此,按照上述方法修改后,应该可以解决。 20. 代码实现: // 头文件 UPROPERTY(BlueprintReadOnly, Category="Attributes") FGameplayAttributeData HP; UPROPERTY(BlueprintReadOnly, Category="Attributes") FGameplayAttributeData MaxHP; // 属性getter ATTRIBUTE_ACCESSORS(UMyAttributeSet, HP); ATTRIBUTE_ACCESSORS(UMyAttributeSet, MaxHP); void UMyAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) { if (Attribute == GetHPAttribute()) { // 钳位HP到[0, 当前MaxHP] NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxHP()); } else if (Attribute == GetMaxHPAttribute()) { // 确保MaxHP至少为0 NewValue = FMath::Max(NewValue, 0.0f); } } void UMyAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) { // 修改MaxHP后,检查当前HP是否超过MaxHP,如果超过则修正 if (Data.EvaluatedData.Attribute == GetMaxHPAttribute()) { float CurrentHP = GetHP(); float CurrentMaxHP = GetMaxHP(); // 注意:由于MaxHP可能被降低,当前HP可能超过了新的MaxHP if (CurrentHP > CurrentMaxHP) { // 通过AbilitySystemComponent设置HP,这将触发一个新的GameplayEffect事件,从而在PreAttributeChange中钳位(不会递归,因为这是独立事件) GetOwningAbilitySystemComponent()->SetNumericAttributeBase(GetHPAttribute(), CurrentMaxHP); } } } 注意:在PostGameplayEffectExecute中,我们不再处理HP属性的修改事件,因为已经在PreAttributeChange中处理了。 21. 最后,请确保在初始化属性时,MaxHP被设置为一个正数,并且初始HP在[0, MaxHP]之间。 22. 如果按照上述修改后仍然出现负数,那么需要检查是否有其他代码直接修改了属性(没有通过GameplayEffect),或者网络同步问题(客户端和服务器不一致)。此外,还要考虑预测机制的影响(客户端预测修改了属性,但服务器回滚时可能产生负数?)。不过,在PreAttributeChange中钳位应该能覆盖所有修改路径。 23. 验证:在PreAttributeChange中加入日志,打印每次修改的值,以及钳位后的值,观察负数是如何产生的。 综上所述,问题原因可能是由于递归导致MaxHP被修改为负数,进而使HP的钳位失效。通过上述双重保障(PreAttributeChange钳位+PostGameplayEffectExecute修正)可以解决。 </think> 在 Unreal Engine 的 `PostGameplayEffectExecute` 函数中,即使使用 `FMath::Clamp` 限制最小值为 0,HP 仍可能溢出为负值的主要原因如下: ### 根本原因分析 1. **递归事件触发导致的钳位失效** 当在 `PostGameplayEffectExecute` 中调用 `SetHP()` 时,会触发新的 GameplayEffect 事件,形成递归调用链: $$ \begin{cases} \text{GameplayEffect 修改 HP} \\ \downarrow \\ \text{PostGameplayEffectExecute 被调用} \\ \downarrow \\ \text{SetHP() 触發新事件} \\ \downarrow \\ \text{再次进入 PostGameplayEffectExecute} \\ \end{cases} $$ 在递归过程中,若中间步骤修改了 MaxHP(如将其设为负值),会导致后续钳位计算失效: `FMath::Clamp(HP, 0, MaxHP)` 在 `MaxHP < 0` 时会直接返回 `MaxHP`,造成负值[^1]。 2. **MaxHP 未被正确钳位** - 若 MaxHP 未被限制最小值(如未在 `PreAttributeChange` 中处理),可能出现: $$ \text{MaxHP} \leq 0 \implies \text{Clamp}(\text{HP}, 0, \text{MaxHP}) = \text{MaxHP} \leq 0 $$ - **典型场景**:当 Debuff 将 MaxHP 减至 0 或负数时,任何 HP 修改都会继承负值。 3. **属性修改顺序问题** 同一 GameplayEffect 中若存在多个属性修改,执行顺序可能导致逻辑冲突: ```mermaid graph LR A[修改 MaxHP = -5] --> B[修改 HP = 10] B --> C[钳位 HP = Clamp(10, 0, -5) = -5] ``` 此处 MaxHP 先被改为负值,后续 HP 修改直接继承负值。 4. **网络预测与服务器同步冲突** 客户端预测机制可能导致临时负值: $$ \begin{cases} \text{客户端预测:} \text{HP} = -5 \\ \text{服务器修正:} \text{HP} = 0 \\ \text{同步延迟期间客户端显示负值} \end{cases} $$ --- ### 完整解决方案 #### 步骤 1:在 `PreAttributeChange` 中实现双重钳位 ```cpp void UMyAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) { if (Attribute == GetHPAttribute()) { // 确保 HP ≥0 且 ≤ MaxHP NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxHP()); } else if (Attribute == GetMaxHPAttribute()) { // 关键:限制 MaxHP 最小值,避免负值传染 NewValue = FMath::Max(NewValue, 1.0f); // 至少为 1 // 同步钳位当前 HP(防止 MaxHP 降低导致 HP 越界) float CurrentHP = GetHP(); if (CurrentHP > NewValue) { // 立即修正 HP 而不触发事件 HP.SetCurrentValue(NewValue); } } } ``` **数学原理**: $$ \begin{cases} \text{MaxHP}' = \max(\text{MaxHP}_{\text{new}}, 1) \\ \text{HP}' = \begin{cases} \text{HP}_{\text{new}} & \text{if } 0 \leq \text{HP}_{\text{new}} \leq \text{MaxHP}' \\ \text{MaxHP}' & \text{if } \text{HP}_{\text{new}} > \text{MaxHP}' \\ 0 & \text{if } \text{HP}_{\text{new}} < 0 \end{cases} \end{cases} $$ #### 步骤 2:重构 `PostGameplayEffectExecute` ```cpp void UMyAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) { // 仅处理 MaxHP 变的后续修正 if (Data.EvaluatedData.Attribute == GetMaxHPAttribute()) { const float CurrentHP = GetHP(); const float ClampedHP = FMath::Clamp(CurrentHP, 0.0f, GetMaxHP()); // 直接修改底层变量,避免事件递归 HP.SetCurrentValue(ClampedHP); } } ``` #### 步骤 3:添加网络同步保护 ```cpp void UMyAttributeSet::OnRep_HP(const FGameplayAttributeData& OldHP) { GAMEPLAYATTRIBUTE_REPNOTIFY(UMyAttributeSet, HP, OldHP); // 客户端同步时二次验证 const float ClampedHP = FMath::Clamp(HP.GetCurrentValue(), 0.0f, GetMaxHP()); if (HP.GetCurrentValue() != ClampedHP) { HP.SetCurrentValue(ClampedHP); } } ``` --- ### 关键验证点 1. **MaxHP 最小值为 1** 通过 `FMath::Max(MaxHP, 1.0f)` 确保分母永不为零或负值。 2. **递归调用链断裂** - `PreAttributeChange` 中直接修改 `HP` 值(非调用 `SetHP()`) - `PostGameplayEffectExecute` 绕过属性系统直接操作数据 3. **网络容错机制** - `OnRep_HP` 在客户端同步时执行二次钳位 - 使用 `GAMEPLAYATTRIBUTE_REPNOTIFY` 确保属性同步安全 > **测试建议**:模拟极限场景(如 MaxHP 被减至 0 时受到治疗),验证 HP 是否稳定在 0~MaxHP 范围内[^1]。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值