Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fewer allocations in Frame classes #44

Open
tbrosman opened this issue Mar 17, 2016 · 3 comments
Open

Fewer allocations in Frame classes #44

tbrosman opened this issue Mar 17, 2016 · 3 comments
Labels

Comments

@tbrosman
Copy link
Owner

I'm weighing the following possible approaches. Design A is what is implemented now. Tangentially related to issue #23 :)

Design A: Complex objects instantiated when getting translation/rotation

interface IFrame2
{
    public var offset(get, set):Vector2;
    public var angleDegrees(get, set):Float;
}
  • Pros
    • You can do expressive things like frame.offset = 30 * Vector2.xAxis + frame.offset
    • It's easy to write adapters for existing code
    • If you alter the x, y, rotation of the adapted class directly (e.g. something else moves your FlxSprite), the getter will reflect the current value (no separate place for keeping track of values)
  • Cons
    • Incurs a large number of allocations
    • Because class variables are instantiated on get, semantics don't always mirror what is actually happening. Example: frame.offset.x += 30 does not alter the frame's x offset (just the x component of the returned Vector2 instance). Note that this isn't the case with Frame2Default/Frame3Default because they cache the variables internally. This is a bug technically since it does not match the behavior you would see with a normal adapter that doesn't cache a copy of the Vector/Quaternion/etc but instead builds it in-place.

Design B: Get/set function adapters for (x, y, rotation) components

interface IFrame2
{
    public var offsetX(get, set):Float;
    public var offsetY(get, set):Float;
    public var angleDegrees(get, set):Float;
}
  • Pros
    • Slightly faster than returning full heap-allocated classes
    • It's still easy to write adapters for existing code, though not quite as easy as the complex objects case (you would have to write 7 component access functions in the case of IFrame3 implementations)
    • No issue modifying components through alternate means
  • Cons
    • It is not possible in the general case to inline getter calls for offsetX/etc so there will be a full function call for each component
    • Less expressive, and adding secondary accessors that create Vector2/Vector3/Quaternion instances causes the same issues as Design A.

Design C: Frame is not an interface but instead contains the (x, y, rotation) components directly

class Frame2
{
    public var offsetX:Float;
    public var offsetY:Float;
    public var angleDegrees:Float;
}
  • Pros
    • The fastest of all options in terms of manipulation, though it potentially means allocating a second class for your sprite/game object's frame
  • Cons
    • Cannot be used as an adapter for existing code without some kind of boilerplate syncing logic (which can cause issues if coordinates are changed in two places)
    • Less expressive (same as Design B)
@player-03
Copy link
Contributor

player-03 commented Aug 28, 2022

I'm not sure I follow the part about not being able to inline getter calls. Other than the fact that you're using interfaces, which are obviously going to prevent it. Do you think users will need to switch between Frame2Default and FlxSpriteFrame2 on the fly? Because I'm pretty sure if you go the Frame2Type route, you could inline everything just fine.

I've never been one to try to tie mathematical constructs to a game engine's objects. I'd rather keep them in sync by hand. Though part of that might be that I don't use Flixel or similar. I write all the code to move, rotate, and so on, then once per frame send it to the engine for rendering. If I was relying on the engine to handle more of the logic, I might actually care about integration.


Anyway, how about a couple more design options?

  • Like Design C, except store the offset as a Vector2. This differs from Design A because here the offset is canonical - changes you make to the vector are saved.
  • Instead of storing the angle in degrees, store an orthonormal basis. This is equivalent to storing a rotation matrix, and massively speeds up the time to get said matrix. Plus it lets you do other neat stuff, such as using orthoNormalize() to calculate the basis from incomplete data, or using Vector2 arithmetic to convert from inside to outside coordinates. Downside is, it's harder to add an angle. (Basically the pros and cons are exactly the same as when considering Cartesian vs. polar coordinates.)

@tbrosman
Copy link
Owner Author

It has been a while since I thought about this. Coming back to it, I think I was trying to do too many things with a single type. Frames were supposed to accomplish two things:

  • Provide an expressive way of representing coordinate frames that could be manipulated and concatenated. Think of the standard position variables for a rigid body in a physics engine minus the mass/moment of inertia/higher derivatives.
  • Build an adapter for existing engines. The rest of hxmath does this by choosing at compile-time which "backend" to use for the abstract.

6+ years later, I think these two concerns should be decoupled. The "Frame" types should only solve representation, and some static adapter class should solve the "sync with existing engines" problem. @player-03 I agree that storing the offset as a Vector2 makes the most sense. All the convenience methods live there already. I also agree that storing it as an explicit member rather than a getter (with slightly weird semantics: see cons of design A) is more straightforward. The performance tradeoff is probably moot in Haxe 4.

As for the rotation part, 2x2 matrices don't convert nicely into angles for all cases. FlxSpriteFrame2 is a (admittedly heavy-handed) attempt to have both and keep them in sync with the downside that you can never set the matrix directly. Exposing the matrix means non-uniform scales, reflections, shears, etc. may be specified. Extracting the angle by using atan2 on one of the basis vectors would give a useless result. One of the principles behind hxmath was to design the APIs so that the client code author doesn't need to know about the edge cases to get the math right.

There's another option for rotations: rotors, which are generalized quaternions. In 2D they look like one of the basis vectors from the rotation matrix. See http://geocalc.clas.asu.edu/GA_Primer/GA_Primer/introduction-to-geometric/rotors-and-rotations-in-the.html for a short derivation. There are fewer degrees of freedom to deal with, though it is still possible to build a rotor that isn't normalized. Building a rotation matrix of a rotor is trivial as well. The downside is that most engines don't work with rotors, so it might just result in more allocations than it is worth. Summary of options:

Option 1: Matrices for concat

  • Option 1A: Store the angle, provide a matrix getter, calculate the matrix on demand.
    • Pros: Changing angle in frames is faster.
    • Cons: Concat is slower, requires creating new rotation matrices.
  • Option 1B: Store the angle and the matrix, calculate the matrix whenever the angle changes, read access to the matrix (potentially with a defensive copy to avoid accidentally modifying it).
    • Pros: Concat is fast.
    • Cons: Changing angle is slower, memory usage is higher.
  • Option 1C: Store a matrix, use atan2 to calculate the angle when needed.
    • Pros: Concat is fast, somewhat compact representation in memory (but not best).
    • Cons: Changing the angle is very slow (requires use of atan2, sin, and cos).

Option 2: Motors for concat

  • Option 2A: Store the angle, provide a rotor getter, calculate rotors/matrices on demand as needed.
    • Pros: Changing angle in frames is faster.
    • Cons: Same as 1A, though concat can be done without creating full rotation matrices.
  • Option 2B: Store the angle and a rotor, calculate the rotor whenever the angle changes, read access to the rotor, calculate matrix on-demand.
    • Pros: Concat is fast (as fast as 1B, but with less memory usage).
    • Cons: Changing angle is slow, about the same or slightly faster than 1B (less memory usage).
  • Option 2C: Store a motor, use atan2 to calculate the angle when needed, calculate the matrix on-demand
    • Pros: Concat is fast, most compact memory representation.
    • Cons: Changing the angle is very slow (requires use of atan2, sin, and cos).

@player-03
Copy link
Contributor

Wow, that was a rabbit hole. Geometric algebra certainly opens up options.

I'd recommend linking to this introductory article as documentation, in addition to or instead of the one you linked above.


(with slightly weird semantics: see cons of design A)

But those weird semantics resulted from using a getter, right? If the vector is a normal variable (or (default, null), or a final variable), then you aren't incurring extra allocations, and frame.offset.x += 30 works just fine.

Exposing the matrix means non-uniform scales, reflections, shears, etc. may be specified.

That's why I suggested storing multiple Vector2s instead of one matrix. Then the user can call orthoNormalize() after making changes. Similar to how you'd normalize a Quaternion if you performed an operation that modified the length.

I definitely see where you're coming from - don't give users the option to shoot themselves in the foot if you can avoid it.

Building a rotation matrix of a rotor is trivial as well.

More so in 2D than in 3D, but yeah.

The downside is that most engines don't work with rotors

From what I've read so far, it sounds like you can use the same underlying type for Rotor3 and Quaternion. You're right for 2D rotors, though.

Changing the angle is very slow (requires use of atan2, sin, and cos).

I don't think that's true.

  • If you're setting the angle to a fixed value, then all you need is sin() and cos() to make a brand new matrix or rotor.
  • If you're adding to the angle, you can do that by concatenating two matrices or rotors. All you need is sin() and cos() to construct the "delta" matrix/rotor, and from there it's much more efficient arithmetic.

As for the big question, I think we should consider use cases. Why go to the trouble of constructing a frame at all? Maybe it's just to store an offset plus rotation and retrieve them later. But more likely, it's for the transformation functionality: you want to convert to/from local coordinates. This functionality either requires a matrix or something equivalent to it (a rotor, a vector's rotate() function, it's all pretty much the same).

  1. If you change the angle several times per coordinate conversion, it's faster to store the angle as a float, and calculate the matrix/rotor only when needed.
  2. If you alternate between changing the angle and doing a coordinate conversion, the approaches are equal.
  3. If you do several coordinate conversions per angle change, it's faster to store the angle as a matrix or rotor, and calculate the float rotation only when needed.

All of the games I've made thus far fall under use case 3. And even if I made a game where the angle was constantly changing, it'd actually change exactly once per frame, and there would always be at least one coordinate conversion in between, resulting in use case 2. I can't think of an example of use case 1 at the moment (and even if I could, it's easy to store your own float and work around the problem).

Conclusion: Option 2C gets my vote.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants