rgbds
rgbds copied to clipboard
[Feature request] Arbitrary precision fixed point math
The built-in fixed-point literals are always Q16.16, but in practice people may want Q24.8 (aka Q8.8 when masked to the low 16 bits), or Q8.24 for high-precision math, or something else. It would be nice to support this without having to do manual shifting and masking.
One proposal: add an OPT
configuration for the current fixed-point precision. OPT f[1-31]
would set the number of digits after the decimal point (so OPT f16
would be the default). All the fixed-point functions, tokenizing, and print-formatting would then adapt to use this dynamic setting.
This would be extremely useful, especially to eliminate the need for macros to convert 16.16 to 12.4, since 28.4 could be used directly. I wonder if it would be a good idea to also make this feature available from the command-line, though. Having to do OPT f4
in every file seems tedious and prone to mistakes, and code relying on a certain size could just do the usual PUSHO \ OPT f24 \ POPO
.
Every OPT
is passable from the command line; they reuse the same switch-setting code. (Luckily there's no rgbasm -f
flag yet.)
Okay, then this seems great. I only worried because you didn't mention a command line flag, which would be especially confusing since opt
would then be taking on a whole different purpose.
I'm not sure I like making this into a global setting, but it may be the least involved solution. The solution I had in mind was to specify it per-expression, i.e. from a function "declaring" fixed-point:
; Precision is Qx.8 ↓↓ so Q23.8 since we *currently* are 32-bit
DEF PLAYER_SPEED equ ROUND[.8](16.0 / 1.75)
; Equivalent to ($0F00 / $01C0) << 8 more or less
This would introduce an extra FIXED
(working title) function for getting the raw fixed-point representation instead of rounding back into integer domain.
The main pro of this syntax is dropping the cumbersome MUL
& co. functions in favor of the usual infix *
et al; however, it requires one of the following:
- Lazy expression evalution (#663): so that we can evaluate the expression knowing the fixed-pointness while avoiding a mid-rule.
- A mid-rule action: so that we can set the precision per-expression while keeping most of our current infrastructure in place. Beware the nesting!
- A lexer hack to recognize the construct and switch the global setting temporarily: a dirty, dirty fallback if the above doesn't work for some reason.
Such contexts could also allow dropping the fractional part (so the above could be written 16 / 1.75
), though that requires careful consideration of whether we want to allow mixing integers in those formulas. I think we don't, but I'm wary of missing something.
So, I see how that would be more terse than the suggested PUSHO/OPT/POPO
and ADD/MUL/etc
functions; but I don't like how it relies on lazy evaluation. It also makes fixed-point literals suddenly meaningful on their own, not just another way of denoting some 32-bit numeric value: "16.0
" and "1.75
" would be of indeterminate precision until ROUND
ed or otherwise operated on, and it would mean we'd have to disallow or specially handle mixed interactions like 3.14 + $100
.
A couple more syntax additions:
-
3.14p8
would specify 3.14 as Q24.8 fixed-point, regardless of the currentOPT f
-
Q.8(expr)
would convertexpr
to Q24.8 fixed-point, from the currentOPT f
precision (so underOPT f12
, it would shift left by 4 bits) (this would be valid forQ.1
toQ.31
, just likeOPT f
)
There's no good reason not to allow 0.32 notation. As for 32.0, I'm not sure it's useful, but there's probably no harm in it.
There's no good reason not to allow 0.32 notation. As for 32.0, I'm not sure it's useful, but there's probably no harm in it.
If there's no weird lexing/calculating edge case, sure. I didn't want to commit to allowing those cases.
So, I see how that would be more terse than the suggested
PUSHO/OPT/POPO
andADD/MUL/etc
functions; but I don't like how it relies on lazy evaluation.
It doesn't, as suggested by my second bullet point: "configure" the expression engine at the mid-rule to switch behavior. Then it requires very little change.
Parsing values accurately only requires reading as many fractional digits as your precision bits, plus one for rounding. (This is because 10^n is divisible by 2^n.) The current parser reads too few, I believe (you need seventeen digits to read a Q16.16 number accurately in decimal), but that shouldn't be hard to fix.
All calculations done on 32-bit fixed-point numbers fit in 64-bit results. This is because the algorithms are, at their core, identical. As always, addition and subtraction can produce up to one excess bit of carry, full products, produce twice the input width, and division, the only one substantially different, only requires shifting the dividend up by the precision — which can at most generate a 64-bit dividend (32 bits from the value + 32 bits shifted).
It doesn't, as suggested by my second bullet point: "configure" the expression engine at the mid-rule to switch behavior. Then it requires very little change.
Correction, "contextual evaluation" not "lazy evaluation". I'm very against the meaning of tokens like 42
or *
changing depending on the surrounding context.
On one hand, I see what you mean, OTOH, it's not different from the meaning of operators changing depending on the operands' types, which are implicitly set from the context (for lack of better typing support, and backwards compat).
While integers would internally behave differently, the end result would be the same, with Q.8(2 * 1.5)
producing the same result as 2 * Q.8(1.5)
.
If Q.8(2 - 1.5)
is the same as 2 - Q.8(1.5)
, then I don't think there's any need for "Q.8
" at all: a global OPT f8
or rgbasm -f8
setting is shorter and more generally useful.
If Q.8(2 - 1.5)
is the same as Q.8(2.0 - 1.5)
, then the meaning of "2
" is context-sensitive.
The Q notation looks somewhat useful for if people are writing complex expressions involving multiple different Qm.n precisions, although that still seems like a rare use case to me to be adding this new kind of token.
If
Q.8(2 - 1.5)
is the same as2 - Q.8(1.5)
, then I don't think there's any need for "Q.8
" at all: a globalOPT f8
orrgbasm -f8
setting is shorter and more generally useful.
But Q.8(2.0 - 1.5)
is different from 2.0 - Q.8(1.5)
(assuming not OPT f8
), that's the issue. And while Q.8(2.0 - 1.5)
is equal to 2.0p8 - 1.5p8
, Q.8(2.0 * 1.5)
is not equal to 2.0p8 * 1.5p8
, which is a problem.
If
Q.8(2 - 1.5)
is the same asQ.8(2.0 - 1.5)
, then the meaning of "2
" is context-sensitive.
Sure it is, but there is no alternative that doesn't sacrifice something else.
The Q notation looks somewhat useful for if people are writing complex expressions involving multiple different Qm.n precisions, although that still seems like a rare use case to me to be adding this new kind of token.
Even if the precisions are all identical, Q.y
is still useful for operators that behave differently for fixed-point and fixed-point-as-integer, chiefly *
.
Even if the precisions are all identical,
Q.y
is still useful for operators that behave differently for fixed-point and fixed-point-as-integer, chiefly*
.
Except that we don't currently have "fixed-point" as a type different from "integer", and I really hope we don't change that. The whole point of fixed-point numbers is that they're just a way of imagining a decimal point in the middle of an ordinary integer (unlike floating-point values which have nontrivial internal structure and conversion from integer 1234 to floating-point 1234.0f).
786432
, $c0000
, 12.0
, and %1100 << 16
are all just ways of spelling the same 32-bit integer value.
+
is the operator for integer addition, which happens to also work naturally for fixed-point arithmetic.
*
is the operator for signed integer multiplication, which is probably not an operation you want to do on values that you're treating as fixed-point.
MUL(x, y)
is the function for fixed-point multiplication, which is just (x * y) >> 16
.
Anyone using fixed-point arithmetic in their game should be aware that it's just an interpretation of integers as being scaled by a constant, e.g. if bc == $1280
then you can think of it as 4736 or as a Q8.8 value 12.5.
The current fixed-point builtins would be fine if Q16.16 were all anyone needed, but other precisions are useful enough that it's worth configuring that somehow. People rarely mix precisions, so making it a global OPT
I think makes sense. Whereas adding a fixed-point "type" and "context", and an explicit Q.#
conversion notation, just for the sake of *
contextually meaning fixed-point vs integer multiplication, IMO is too complex to bother.
I disagree still because of the discrepancy of treating them like integers but actually not, but I'll yield on this one, then.