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

Enforce read-only global transform #1460

Closed

Conversation

TheRawMeatball
Copy link
Member

This PR makes the GlobalTransform fields private to enforce read-only behavior which is expected by the transform propagation.

@Ratysz Ratysz added C-Code-Quality A section of code that is hard to understand or change A-ECS Entities, components, systems, and events labels Feb 17, 2021
@Ratysz
Copy link
Contributor

Ratysz commented Feb 17, 2021

Are we sure there is no case when user code would need to mutate the global transform directly?

@Davier
Copy link
Contributor

Davier commented Feb 17, 2021

As mentioned previously, I want to experiment removing the Transform component on ui bundles and directly set GlobalTransform.

@rparrett
Copy link
Contributor

rparrett commented Feb 17, 2021

I don't know if this is what you would call a "valid use case," but in the past I was messing with GlobalTransform because I needed to reposition some stuff relative to some text as it changed sizes with Changed<CalculatedSize>. Worked great / glad I was able to do that.

But that wouldn't be needed if Changed gets upgraded obviously.

Edit: potentially also not needed if labels get added to these systems and text size is calculated prior to transform propagation?

@MinerSebas
Copy link
Contributor

MinerSebas commented Feb 17, 2021

Perhaps create an extension trait that isnt Part of the Prelude and still allows setting the GlobalTransform.
This would allow advanced Users to import the trait, while preventing Pitfalls for average Users.

Base automatically changed from master to main February 19, 2021 20:44
@cart
Copy link
Member

cart commented Mar 6, 2021

I think there are a few perspectives we can take here:

  1. GlobalTransform can be set directly. It can stand alone without Transform, which is a "local" transform that is only added to things that can exist in a hierarchy. If Transform is added, it must be used to make modifications because it overrides manually set GlobalTransform values. This is the "current" approach.
    • pros:
      • Enables "smaller" entities when they don't use hierarchy
      • Transform->GlobalTransform syncs don't need to happen for things that don't use hierarchy
      • GlobalTransform-only is "simple". No underlying goo.
    • cons:
      • The "mental model" / edit interface changes based on whether or not something can be in a hierarchy
      • Users that don't understand the system will have their GlobalTransform changes implicitly overwritten, which is confusing.
  2. GlobalTransform cannot be set directly. It can not stand alone without Transform (or at least, it will always have the default value if there is no Transform). Transform is always the interface for transform modification. This is the "proposed" approach.
    • inverse of pros / cons in (1)
  3. Use three types: GlobalTransform (component with private fields), LocalTransform (component with private fields), Transform (custom query type). The Transform query would internally be something like Query<(Option<GlobalTransform>, Option<LocalTransform>), Or<With<GlobalTransform>, With<LocalTransform>)>. It would expose a consistent interface that internally does the right thing depending on what components are present. The only way to modify transforms is via the Transform query methods.
    • pros:
      • consistent interface
      • prevents users from doing the "wrong" thing on accident
    • cons:
      • another type with another set of methods
      • the internal Transform component checks adds overhead
      • harder to reason about the internal implementation
      • people would probably be confused about Transform and think it was a component.

I included (3) mainly as a thought exercise. I think the added complexity isn't worth it, despite the consistent interface it provides. (1) and (2) are both "valid" in my mind, its just a matter of picking the tradeoffs we want.

@alice-i-cecile
Copy link
Member

If we're using approach 1, Transform should be renamed to LocalTransform, as raised before by @aevyrie. Transform very much feels like the obvious, default thing that you should always be using, and many beginners (including myself) attempt to add and manipulate it in their systems rather than using GlobalTransform.

Personally, I prefer approach 2: the simplicity of being able to manipulate a single component no matter whether or not we're in a hierarchy seems really valuable for refactors that you'll see during prototyping and the mental model is much simpler.

I agree that approach 3 is far too elaborate for its benefits.

@Davier
Copy link
Contributor

Davier commented Mar 7, 2021

Transform very much feels like the obvious, default thing that you should always be using, and many beginners (including myself) attempt to add and manipulate it in their systems rather than using GlobalTransform.

Transform is the only one you should be manipulating, usually. The issue I've seen come up in discord is when people make their own bundles and only add Transform instead of both, in which case Transform stops working. Making the fields of GlobalTransform private won't help with that.

the simplicity of being able to manipulate a single component no matter whether or not we're in a hierarchy seems really valuable

That's already the case, if both are included you can continue to use Transform even outside of a hierarchy.

Maybe it would be clearer by having a TransformBundle that includes both, and is nested in all the other standard bundles. We could also remove GlobalTransform from the prelude.

My preference is on the current approach, since the second one prevents (or makes expensive) an (uncommon) use case I care about.

@cart
Copy link
Member

cart commented Mar 7, 2021

Yeah calling the "local transform" Transform was an intentional decision to bias users toward the "correct" behavior. 99% of the time they should be setting the "local transform". This is how other engines / frameworks generally approach naming and I think its the right call. I don't think we should rename that unless we roll with something like (3).

TransformBundle might help with the "missing component" problem, but it also increases the boilerplate for what is probably the most common component:

// current
commands.spawn(SpriteBundle {
    transform: Transform::from_xyz(1.0, 0.0, 0.0),
    ..Default::default()  
 })
 
// nested bundle
commands.spawn(SpriteBundle {
    transform_bundle: TransformBundle {
        transform: Transform::from_xyz(1.0, 0.0, 0.0),
        ..Default::default()
    },
    ..Default::default()  
 })

Given the frequency of bundle usage vs bundle declarations, I don't think thats a worthwhile tradeoff (at least for core bundles). I guess editor workflows might benefit from a TransformBundle. And custom user bundles might sometimes prefer that (although I would personally never use that in my custom bundles for ergo reasons).

GlobalTransform+Transform will be the common case no matter what, so its probably worth optimizing the ux for that case. In that context, GlobalTransform should never be set. We need to weight that against the benefits we get from standalone GlobalTransform use cases.

I think we should enumerate those use cases and weigh their value against the "foot-gun reduction" this pr adds.

@cart cart mentioned this pull request Mar 7, 2021
@aevyrie
Copy link
Member

aevyrie commented Mar 7, 2021

What if Transforms were enums?

enum Transform {
    Local {
        pub translation: Vec3,
        pub rotation: Quat,
        pub scale: Vec3,
    },
    Global {
        pub translation: Vec3,
        pub rotation: Quat,
        pub scale: Vec3,
    },
}

Pros:

  • This would ensure you can only have one type of transform on an entity, which removes the confusion around which one to use.
  • Makes the global/local more discoverable - you don't need to know that there is a separate GlobalTransform entity type, and the variant usage can be documented in one place: on the enum struct.

Cons:

  • Because enum variants don't have types (yet), this means we can't match against the type of transform for things like transform propagation.

Alternatively, we could use generics here, which would be very similar to the current situation, except the transform types would be addressed through a single generic transform type, something like:

struct Transform<T: IsTransform> (T);

trait IsTransform {
    // from_xyz, etc
}

struct Local {
        pub translation: Vec3,
        pub rotation: Quat,
        pub scale: Vec3,
}
impl IsTransform for Local {}

struct Global {
        pub translation: Vec3,
        pub rotation: Quat,
        pub scale: Vec3,
}
impl IsTransform for Global{}

This would allow you to query the type of transform if needed for propagation and such, like Transform<Local> and Transform<Global>. We could make the fields private as needed, and only work through setters/getters depending on whether it is a global or local. This could also open the door for more complex user-extensible transform types, without needing to add this functionality into bevy itself - all they need to do is impl IsTransform.

Regardless, I think this would make it much more clear compared to having two separate component types, and documentation could be put in the Transform<T> struct.

@cart
Copy link
Member

cart commented Mar 8, 2021

Those are interesting ideas, but they don't solve the problem this pr is addressing (users have the ability to override GlobalTransform values set by the hiearchy system). The suggestions are more about "discoverability' (in the case of the enums) and code-size reduction / consistency (the generic).

@Davier
Copy link
Contributor

Davier commented Mar 8, 2021

GlobalTransform+Transform will be the common case no matter what, so its probably worth optimizing the ux for that case. In that context, GlobalTransform should never be set. We need to weight that against the benefits we get from standalone GlobalTransform use cases.

Making it difficult/obscure to change GlobalTransform is completely fine by me, as long as it's not completely impossible. Sometimes you need something more complex than what transform_propagate_system does (e.g. for the z-index property).

@aevyrie
Copy link
Member

aevyrie commented Mar 8, 2021

Those are interesting ideas, but they don't solve the problem this pr is addressing (users have the ability to override GlobalTransform values set by the hiearchy system). The suggestions are more about "discoverability' (in the case of the enums) and code-size reduction / consistency (the generic).

I should have given some more context to that comment. This was in response to your earlier suggestions

I think there are a few perspectives we can take here: [...]

If we plan to change the structs/components as an alternative to this PR, such as your 3rd suggestion, I thought the ideas I brought up would be worth discussing. While those suggestions are more about "discoverability" as you said, they do have implications for the problems this PR solves. The enum route, for example, wouldn't have this issue because you would be guaranteed that there is only one type of Transform component present on an entity, and thus, you should be able to access and edit its fields.

If these larger type changes are out of scope for this discussion, then I am in full agreement with @alice-i-cecile's comment.

@cart
Copy link
Member

cart commented Mar 8, 2021

The enum route, for example, wouldn't have this issue because you would be guaranteed that there is only one type of Transform component present on an entity, and thus, you should be able to access and edit its fields.

This isn't something that we want to ensure. The only two valid configurations are [GlobalTransform] and [GlobalTransform, LocalTransform]. LocalTransform by itself has no meaning.

@aevyrie
Copy link
Member

aevyrie commented Mar 8, 2021

The enum route, for example, wouldn't have this issue because you would be guaranteed that there is only one type of Transform component present on an entity, and thus, you should be able to access and edit its fields.

This isn't something that we want to ensure. The only two valid configurations are [GlobalTransform] and [GlobalTransform, LocalTransform]. LocalTransform by itself has no meaning.

Yeah, the LocalTransform would need to be doubly sized in the case of the enum, to hold both a local and global transform in the LocalTransform variant. That would be encapsulated by only exposing setters/getters to the users of the API. This would come at the expense of the enum taking double the memory, and only being 50% memory efficient when using the global variant of the enum.

The generic route wouldn't suffer from the memory downside though, as Transform<Local> and Transform<Global> could have different size.

My goal with these suggestions is to address the root cause driving this PR. There are two ways an entity's transform can be expressed:

  1. In world space, i.e. GlobalTransform

    • In this case, the local transform is not used
    • The user should read and write to/from GlobalTransform
  2. Relative to a parent, i.e. LocalTransform

    • The GlobalTransform is derived from the LocalTransform
    • The user should read from GlobalTransform and write to LocalTransform
    • The LocalTransform needs to be persisted across frames, we can't just convert it into a GlobalTransform

Currently, we have two independent components which do not make their relationship clear, nor do we use the type system to prevent misuse. I'm advocating for a variant of your "perspecting 3", which (I think) solves most of the cons, as well as:

GlobalTransform+Transform will be the common case no matter what, so its probably worth optimizing the ux for that case. In that context, GlobalTransform should never be set. We need to weight that against the benefits we get from standalone GlobalTransform use cases.

Consider this tweaked version of the generic transform I suggested above:

pub struct Transform<T: IsTransform> (T);
// IsTransform trait and impls not shown

struct Global {
    translation: Vec3,
    rotation: Quat,
    scale: Vec3,
}

struct Local {
    translation: Vec3,
    rotation: Quat,
    scale: Vec3,
    global_transform: Option<Global>
}

This lets us:

  • Explicitly express when the global transform has or has not been propogated (None/Some)
  • Continue to use the same Global transform type that is used in all the render systems
  • Only use as much memory as we actually need (Transform<Local> vs Transform<Global>)
  • Access can be customized for each type, e.g. for Local you would not be able to write to global_transform, only the propogation system could do this.
  • We can use the IsTransform trait to define a stable user facing API, e.g. reading a transform would always return a result in world space using .get_world_transform() -> Option<Global> or something. Maybe this could be a Result with an error instead?

@Davier
Copy link
Contributor

Davier commented Mar 8, 2021

An issue is that we could not query over GobalTransform

@aevyrie
Copy link
Member

aevyrie commented Mar 8, 2021

You can though! You would query over Transform<T> and get the global transform with IsTransform::get_world_transform().

The added benefit is now you know when you are reading from a transform that has not yet propagated, because this design will return a None (or error if we went that route) instead of blindly returning you a stale or invalid GobalTransform.

This could make queries messy though, as you may need to specify both concrete versions of the type for the query, though there might be a way around this using trait objects.

@Davier
Copy link
Contributor

Davier commented Mar 8, 2021

This could make queries messy though, as you may need to specify both concrete versions of the type for the query, though there might be a way around this using trait objects.

I'm all for being able to query traits when it makes sense, but I don't think it does here. Transforms are too fundamentals, they need to be as performant as possible. Even the extra discriminant check of an enum should be avoided IMO.

@aevyrie
Copy link
Member

aevyrie commented Mar 8, 2021

Yeah, I don't think enums are the way to go, which is why I've been continuing down the generics route. Or are you concerned that the Option<> inside Local is going to be a performance bottleneck? Couldn't you just unwrap it in release mode (which is generally optimized away) and panic instead of using incorrect data?

Trait objects were a suggestion to aid the UX of querying Transform<T> as a single type. Maybe there's a zero-cost alternative?

@alice-i-cecile
Copy link
Member

This isn't something that we want to ensure. The only two valid configurations are [GlobalTransform] and [GlobalTransform, LocalTransform]. LocalTransform by itself has no meaning.

To me, this is one of the more serious bits of confusion with the current API. When an entity has no parent, Transform behaves like a GlobalTransform. But as soon as you add a parent, Transform behaves like a LocalTransform.

@cart
Copy link
Member

cart commented Mar 8, 2021

Basically the only reason we don't currently do a combined transform is performance. Putting them together means that we're pulling in the global transform values every time a local transform value is needed (and local-only is the common case). This is less cache friendly.

I also don't see the value of the generics. Transform<Local> and Transform<Global> could just be LocalTransform and GlobalTransform. Putting them inside a wrapper just to "correlate" them sends the wrong message about the implementation (that they have a shared internal implementation).

@alice-i-cecile
Copy link
Member

So, my overall feeling here is that LocalTransform and GlobalTransform are too tightly coupled to be sensibly exposed as distinct interfaces. Combine that with parent-child coordination working once-per-loop in the background using a system in the default plugins and it's apparent why we have so much end user confusion (and annoying bugs).

In the absence of any performance concerns at all, it feels like the most natural API for this would be:

pub struct TransformData {
    translation: Vec3,
    rotation: Quat,
    scale: Vec3,
}

pub struct Transform{
  pub global: TransformData
  local: Option<TransformData>
}

// Getter and setter methods for  the local fields of Transform
// When you set the local field of Transform, the global field is automatically set to match

Unfortunately, we're also quite performance-limited here, because of how central and relatively costly transform components and updates are. Taking the example of a realistic graphics-intensive game though, almost all Transform entities will be part of an object hierarchy of some sort: as part of a UI, as a piece of a whole that gets animated, as part of a graphics effect or so on.

Of those within the object hierarchy, only those objects at the very start of the hierarchy will not have a local transform. These, by the nature of trees, will be relatively rare. As a result, I'm not terribly concerned about the extra memory allocation of doubling the size of the Transform type in terms of bulk storage.

After reading @cart's most recent comment though, I'm afraid he might be right: coupling the two into a single component inherently causes more data to have to be fetched when we only care about a single field, and only caring about a single field (on a per-system, rather than per-entity basis) is very common.

@alice-i-cecile
Copy link
Member

This isn't something that we want to ensure. The only two valid configurations are [GlobalTransform] and [GlobalTransform, LocalTransform]. LocalTransform by itself has no meaning.

To me, this is one of the more serious bits of confusion with the current API. When an entity has no parent, Transform behaves like a GlobalTransform. But as soon as you add a parent, Transform behaves like a LocalTransform.

As a follow-up to this: what if every Transform entity had a parent and both a GlobalTransform and a LocalTransform that always worked in consistent ways? The currently unparented entities would be the children of a global reference frame entity. This could help improve conceptual clarity without hurting hot-loop performance, and allow us to make GlobalTransform a private component (or perhaps just something that should almost never be touched).

@aevyrie
Copy link
Member

aevyrie commented Mar 8, 2021

Yes, this leads us back into the land of AoS vs. SoA and the cache tradeoffs.

At the risk of derailing this conversation, this makes me think, again, that what we need is a way to express component dependencies. This is something I've brought up in the past with other component types. All of this discourse and PR revolve around the fact that there is some relationship between the transform component types that we wish to express to users of the API. Maybe this is something that can be added in component bundles?

@alice-i-cecile
Copy link
Member

At the risk of derailing this conversation, this makes me think, again, that what we need is a way to express component dependencies. This is something I've brought up in the past with other component types. All of this discourse and PR revolve around the fact that there is some relationship between the transform component types that we wish to express to users of the API. Maybe this is something that can be added in component bundles?

If we had full archetype invariants (#1481), what I would do is:

  1. Create a TransformBundle that contained a GlobalTransform and LocalTransform.
  2. Specify "LocalTransform is always_with GlobalTransform".
  3. Note this in the docs, along with suggestions on which one to modify and when.
  4. Allow users to explore these relationships in our magical visual archetype invariant visualization tool ;)

@cart
Copy link
Member

cart commented Mar 8, 2021

Yeah I think we've clearly isolated the core issue. Heres my take: allowing GlobalTransform to be set in non-hierarchical contexts creates confusion. A "combined transform" doesn't solve that inherently (unless we abstract that out internally and pick what is set based on the context, which is bad news bears in my opinion).

I think "GlobalTransform and LocalTransform must appear together and always behave the same way" follows from that. Archetype invariants are a nice-to-have that would help enforce that constraint, but they aren't needed to start following it / assuming it.

If GlobalTransform and LocalTransform must behave the same way in all contexts, and GlobalTransform cannot be written to directly in hierarchy, then that means we should make GlobalTransform read-only (regardless of context). This has the added benefit of disallowing "incorrect" behavior if someone only adds a GlobalTransform. There wouldn't be errors, but users would be unable to write code that sets the transform.

From there, that means that contexts that don't want "hierarchical transforms" will need to use a different component. Cases like UI could either create a new UiTransform component or extend an existing component like Node.

Thoughts?

@alice-i-cecile
Copy link
Member

alice-i-cecile commented Mar 8, 2021

I think "GlobalTransform and LocalTransform must appear together and always behave the same way" follows from that. Archetype invariants are a nice-to-have that would help enforce that constraint, but they aren't needed to start following it / assuming it.

Agreed.

If GlobalTransform and LocalTransform must behave the same way in all contexts, and GlobalTransform cannot be written to directly in hierarchy, then that means we should make GlobalTransform read-only (regardless of context). This has the added benefit of disallowing "incorrect" behavior if someone only adds a GlobalTransform. There wouldn't be errors, but users would be unable to write code that sets the transform.

I like this.

From there, that means that contexts that don't want "hierarchical transforms" will need to use a different component. Cases like UI could either create a new UiTransform component or extend an existing component like Node.

Yes: this is the correct behavior. We should be sure that it is trivial to render entities with only a simple Transform component though. Maybe introduce a SimpleTransform component or the like and use that in UI? That name needs work though, so suggestions are welcome...

@cart
Copy link
Member

cart commented Mar 8, 2021

Yes: this is the correct behavior. We should be sure that it is trivial to render entities with only a simple Transform component though. Maybe introduce a SimpleTransform component or the like and use that in UI? That name needs work though, so suggestions are welcome...

I'm not sure I agree. Adding another transform type for "simple" use cases makes the system harder to reason about. Then we have 3 types for people to sift through (GlobalTransform, LocalTransform, and SimpleTransform). What happens when someone Parents a SimpleTransform to a SimpleTransform? Or a SimpleTransform to a GlobalTransform?

It also gets more complicated because SimpleTransform and LocalTransform are different types and must be queried for separately.

Imo its simpler to just say "Add both LocalTransform and GlobalTransform" (or TransformBundle) if you want your entity to have a transform. Its just one extra component and it gives us consistency.

(Also to be clear, I still think "LocalTransform" should be called Transform to bias toward the correct behavior)

@cart
Copy link
Member

cart commented Mar 8, 2021

I guess we could also consider an alternative: we could consider treating GlobalTransform as a read-only product of hierarchy, and selectively use it in place of Transform when it is present. It would require some additional internal complexity for data binding, but its something users wouldn't need to care about.

The reason we've avoided that in the past is that it makes it hard to query for the "source of truth" world space position. Users need to check for the presence of GlobalTransform and use that in place of Transform when appropriate.

@cart
Copy link
Member

cart commented Mar 8, 2021

So yeah im still biased toward (Transform, GlobalTransform) pairs for userspace and one-off newtypes for things that can't operate under those constraints (which should be relatively uncommon)

@cart
Copy link
Member

cart commented Mar 8, 2021

Lol this is hard. I'm sort of coming around to the "make GlobalTransform hard to set but not impossible" idea put forward by @MinerSebas. That would enable ui-like-scenarios (where GlobalTransform is just a derived value, much like it is in normal hiearchy cases) without new types.

@cart
Copy link
Member

cart commented Mar 8, 2021

struct GlobalTransform {
   transform: Transform,
}

impl GlobalTransform {
  pub fn translation(&self) -> Vec3 {
      self.transform.translation
  }
  
  /// Mutably access the internal world space translation
  /// # WARNING
  /// this is rarely what you want. (explain why here)
  pub fn translation_internal_mut(&mut self) -> &mut Vec3 {
      &mut self.transform.translation
  }
}

Edit: reusing Transform probably isn't worth it here

@alice-i-cecile
Copy link
Member

I like that draft: clearly signals how it should be used without a lot of overhead and doesn't force users to think hard about how to query for a source of truth. I agree that reusing Transform just makes things less clear though.

This change plus a bit better documentation plus #1471 should greatly reduce the footguns involved in prototyping with transforms.

@Davier
Copy link
Contributor

Davier commented Mar 8, 2021

I could live with that, but I'll try to make my case one last time.
I like the current transforms. The mental model is:

  • the GlobalTransform component places the entity in the world,
  • the Transform component automatically sets the entity's GlobalTransform relative to its parent's GlobalTransform.

That's all. It's simple, efficient, extensible and standard. I don't find it incoherent or unclear. If some beginners trip up on it, it only shows our lack of documentation, let's fix that instead.

@cart
Copy link
Member

cart commented Mar 8, 2021

Solid points. Thanks pushing back. This issue is not clear cut and adding restrictions does complicate the mental model in some ways. This change is also breaking, so I think rushing it in would be a mistake given how much back and forth there is (I've already changed my mind a couple of times in this thread). I'm thinking we should hold off for now in favor of doc improvements and continue this conversation in a Github Discussion. Any objections?

@alice-i-cecile
Copy link
Member

alice-i-cecile commented Mar 8, 2021

@cart that seems sensible. We can close this PR, and link this thread in the discussion. Then, I'll make an issue for better docs and give it a 0.5 milestone so we can be sure it's out in time.

We should expect another influx of new users once 0.5 goes live, and so we'll be able to see how well it works.

@aevyrie
Copy link
Member

aevyrie commented Mar 8, 2021

Yep, this sounds reasonable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ECS Entities, components, systems, and events C-Code-Quality A section of code that is hard to understand or change
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants