Game Development Community

0 != 0 with writeSignedFloat/readSignedFloat

by Michael Reino · in Torque 3D Professional · 02/25/2014 (3:27 pm) · 5 replies

I expected to lose some precision when reducing an F32 to fewer bits. I also mistakenly expected that if I wrote 0.0 into the bitstream, I would read 0.0 back out. The functions are:
void BitStream::writeSignedFloat(F32 f, S32 bitCount)
{
   writeInt((S32)(((f + 1) * .5) * ((1 << bitCount) - 1)), bitCount);
}

F32 BitStream::readSignedFloat(S32 bitCount)
{
   return readInt(bitCount) * 2 / F32((1 << bitCount) - 1) - 1.0f;
}
By design, if you write 0.0, readSignedFloat will return -(1/(2^bitCount)). A common usage for readSignedFloat is (from Player::unpackUpdate):
rot.z = stream->readSignedFloat(7) * M_2PI_F;
This created a player twitch for me because I have players that are mounted to another object yet are still ticking (because I need the head threads updated). mRot.z gets forced to 0.0 on the server yet the client reads -0.0078 which gets multiplied by 2Pi to become -0.049. This results in a visible twitch as the player rotation snaps back and forth between 0 and -0.049 radians. I eliminated my player twitch by changing these functions to:
void BitStream::writeSignedFloat(F32 f, S32 bitCount)
{
   if ( writeFlag(f < 0.0f) )
      f *= -1.0f;

   writeInt((S32)(f * ((1 << (bitCount - 1)) - 1)), bitCount - 1);
}

F32 BitStream::readSignedFloat(S32 bitCount)
{
   if ( readFlag() )
      return readInt((bitCount - 1)) / F32((1 << (bitCount - 1)) - 1) * -1.0f;

   return readInt((bitCount - 1)) / F32((1 << (bitCount - 1)) - 1);
}
These use the same number of bits in the bitstream and maintain the same degree of accuracy. The only advantage is that if I write 0.0, 0.0 is the value returned from readSignedFloat. The disadvantage is that it adds some extra instructions to functions that get called very frequently by the engine.

#1
02/25/2014 (3:38 pm)
Quote:
These use the same number of bits in the bitstream [and maintain the same degree of accuracy]

Aren't you losing one bit of precision for each float you write in this manner, though?
#2
02/25/2014 (4:05 pm)
@Stefan
No, because the dropped bit (used for flag) is offset by not including the ((f + 1) * .5) factor before converting to S32. The * .5 has the same effect.
#3
02/25/2014 (5:15 pm)
A better explanation is:
writeSignedFloat works for values of f in the range -1 to 1. Before adding to the bit stream it shifts the input value into the range 0 to 2 (f+1). It then compresses this range down to 0 to 1 (*.5) and writes with bitCount precision. Yielding 2^(bitcount-1) possible values on both sides of zero.

My change accepts values in the same range, -1 to 1, sends a sign bit, then sends the absolute value of f (no compression of f) with bitCount-1 precision. Which also yields 2^(bitCount-1) possible values on both sides of zero.
#4
02/26/2014 (12:06 am)
@Stefan
After reexamining my code, you're right, there is a loss with my method. It's a loss of a single degree of freedom though and not a loss of one bit of precision. The significance of the lost degree of freedom increases as bitCount decreases. The loss is because this method can represent a positive and negative zero which is redundant. The stock methods can represent 2^bitCount distinct values. The method I posted can represent (2^bitCount)-1 distinct values.

It's that extra degree of freedom that's the core of my initial problem though. If the method can represent an even number of values and zero falls in the middle of the range, then it will always reside in the middle between two represented values. Rounding down to the nearest represented value will always skew zero slightly negative as I was seeing. For my application, the precision at zero outweighs the lost degree of freedom, but might not be appropriate for most users.
#5
02/26/2014 (2:01 am)
If you want to keep the precision, have you tried writing and reading as a float instead of an integer?

void BitStream::writeSignedFloat(F32 f, S32 bitCount)
{
   writeFloat(mFabs(f), bitCount - 1);
   writeFlag(f < 0.f);
}

F32 BitStream::readSignedFloat(S32 bitCount)
{
   return readFloat(bitCount - 1) * (readFlag() ? -1.f : 1.f);
}

EDIT: Or not. writeFloat is still writing an integer, so this may do nothing.