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

Dedicated 2D Transform #82

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions rfcs/82-transform2d.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Dedicated 2D Transform

## Summary

The ergonomics of using Bevy's existing Transform for 2D games is poor. To counter this a 2D Transform component should be created that has improved ergonomics.

## Motivation

While it is readily possible to create 2D games in Bevy today, interacting with the existing Transform system is awkward, high in "ceremony", and prone to unintentional human error. There are two main issues:

1. Translation is represented as a `Vec3` with the draw order set implicity based on the Z value
2. Rotation being represented as a Quat even though all rotations are around the Z axis

### The Problem with Translation

Translation in Bevy is stored as a `Vec3` in the Transform component. This is fine for 3D games, and is usable for 2D games, but has several issues.

#### Overloading the Concept

In 2D games this `Vec3` represents two seperate concepts - the 2D position (X,Y) of the entity, and the draw order (Z) of the entity. Vector maths, thus, can end up changing the draw order value when done on the full `Vec3`, leading to graphical issues being caused by systems that seem unrelated to graphics. This can be easy to forget, and difficult to debug. Avoiding this requires writing a lot boilerplate, which leads to...

#### Poor Ergonomics

In order to avoid issues caused by the above overloading any system that interacts with an entities translation will often store the entities Z, copy a `Vec2` of the translation using either xy() or truncate(), perform any necessary operations, then re-extend the `Vec2` back to a `Vec3` with the stored Z before reassigning the value to the Transforms translation. The effect of this is that a 2D games code is littered with truncate() and extend(). This is then further exasperated when doing operations like Raycasting where maintaining the existing Z value will give incorrect results (including Z distance in ray calculations). All in all, this is a poor user experience.

### The Problem with Rotation

Rotation in bevy is stored as a `Quat` in the Transform component. Again, this is fine for 3D games, and is usable for 2D games, but has poor ergonomics. Rotation in a 2D game in Bevy is always counterclockwise around the Z axis. As with using `Vec3`'s for Translation this causes a lot of boilerplate, extracting the value of a quaternions rotation around Z in radians, performing calculations on that value, then creating a new `Quat` rotating around Z before reassigning. Much as with Translation, this is a poor user experience.
Copy link

@NthTensor NthTensor Jun 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say that most but not all rotation is about Z, or possibly about the camera normal. There are reasons to want 2d rotation about a different axis, or affine rotations which mimic the effect of rotation beyond the plane. But I'm being pedantic.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be fine to give Transform3d to something you wanted the extra flexibility on. I think it should be trivial to mix and match given they all land in the same global transform.


## User-facing explanation

Transform2D represents a 2D entities position, rotation, and scale in world space. It is defined as follows:

```rust
pub struct Transform2d {
position: Vec2,
rotation: f32 //RFC Note: We could use Rotation2d here

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will 100% want this to be Rotation2d.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool. I'll make that change

scale: Vec2

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is sidestepping the problems with nonuniform scale, and is a feature regression from the current. But I'm ok to leave that out for now and tackle 2d/3d scale at the same time in follow up, so long as it lands in the same release cycle.

Copy link
Author

@BobG1983 BobG1983 Jun 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...I'm probably going to need to have someone talk to me live (discord?) about this so I can understand. I've read the threads, I've read a post about it....and I feel like I'm being gaslit when it comes to "the problems with nonuniform scale". So I'm going to leave this for now and come back when I don't feel like a crazy person and clearly understand the problem.

}
```

If you're making a 2D game and you want your entity to appear in the world at a given location it must have a `Transform2D` component. `Position` represents your entities location in the world (actually it's position on the 'xy-plane'). `Rotation` represents your entities anti-clockwise rotation in radians (around the Z-axis). `Scale` represents the entities scale in the world (like `Position` this is actually the entities scale on the 'xy-plane').

Many entities in a 2D game will have a sprite, a mesh, or some other graphics attached to them. By default, all these entities are drawn on the same 'layer'. When entities are drawn the last entity drawn appears on top of thosed drawn before it in the same location. Developers can control which layer an entity is on by changing that entities `DrawLayer`:

```rust
pub struct DrawLayer(pub f32);
```

As you can see `DrawLayer` is just a wrapper around an `f32`. Entities with a higher value for their `DrawLayer` will be drawn on top of those with lower values.
Comment on lines +46 to +50

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is one of the approaches that's been tossed around for z ordering, and personally I'm not a huge fan.

There are a few issues.

  1. Floating points are not totally ordered, and by exposing them to users for ordering we invite them to make mistakes (rather than just using them for ordering internally, where we can ensure correctness). It's not clear to me what should happen if you have a NAN layer, or if negative draw layers should be allowed, or what the ordering properties for negative vs positive zero should be. Negative and positive infinity do seem fine, and I'll note that there are ways to make floats Ord (but they are not free).
  2. You can exhaust the precision of the space; inserting a layer between two other layers can fail, require moving an arbitrary number of layers to make room. I'm not sure how common this will be in practice, but I suspect with enough nesting it will be something people run into repeatedly.
  3. It seems like to determine z position, you sum the layers of all the ancestors and add it to the layer of the entity. This probably means storing a PropigatedLayer(f32) component, which seems like it would add complexity. "Layer" also dosn't seem like the right term to describe this; perhaps "z-offset"?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this after I wrote the RFC, but honestly, I would prefer this to be a u32 rather than an f32.

Also yes, that's my suggestion, summing all the layers of the parents values and adding it the childs.


## Implementation strategy

### Components

#### Transform

A `Transform2d` component should be created as follows:

```rust
pub struct Transform2d {
position: Vec2,
rotation: f32 //Note: Optionally a Rotation2d
scale: Vec2
}
```

The existing `Transform` component should annotated as `[deprecated]` and a copy created named `Transform3d`.

#### DrawOrder

A `DrawOrder` component should be created as follows:

```rust
pub struct DrawLayer(pub f32);
```
For entities with parent/child relationships, child entities should have their `DrawOrder` propogated to an offset from their parents `DrawOrder` as with the existing `Transform` component. We should not do anything more than this (ie. we should not attempt to automatically set child entities to some lower value of `DrawOrder` than their parent).

### Bundles

#### Spatial2dBundle

A new spatial bundle should be created as per the existing `SpatialBundle`, but containing a `Transform2d` instead of a `Transform`.

#### SpriteBundle/SpriteSheetBundle

Two new bundles should be created `Sprite2dBundle` and `SpriteSheet2dBundle` matching the existing `SpriteBundle` and `SpriteSheetBundle` except replacing `Transform` with `Transform2d`.

The existing `SpriteBundle` and `SpriteSheetBundle` should be annotated as `[Deprecated]` and copies created named `Sprite3dBundle` and `SpriteSheet3dBundle`.

### Systems

New versions of `sync_simple_transforms`, `propogate_transforms`, and `propogate_transforms_recursive` should be written that propogate the new `Transform2d` into the existing `GlobalTransform`.

## Drawbacks

### Maintenance Burden

Beyond having three new systems and some new components to maintain Bevy as a whole would need to choose to either:

* Offer `Vec2` versions of many of it's `Vec3` functions, potentially labelling as eg. `RayCast2d` vs `RayCast`
* Require developers to call `extend()` and use the `Vec3` versions of these functions. Which may defy the point of doing this in the first place.

This burden is unlikely to go away once this decision is made, for the lifetime of the engine.

### Ecosystem Pressure

As above, many ecosystem crates (`bevy_xpbd`, `bevy_rapier`, for example) would need to answer the same question. Either offering `Vec2` versions or requiring use of `extend()`. In some cases these crates already do this, but this would 'force the question'.

### One Way Door?

Once this door is passed through, it may be difficult to walk back, its likely that UI will want similar versions of `Transform` for their needs and ecosystem crates would likely start to support `Transform2d`. Once this change is made, it is likely made permanently.

## Rationale and alternatives

There are really only two options:

* Have a `Tranform2d` component
* Don't have a `Transform2d` component

The existing system has poor ergonomics, not making this change will continue to favor the development of 3D games, make 2D games more difficult to develop and more prone to error. I believe this may, in the long term, harm uptake of the engine given the large proportion of indie games are 2D and Indie devs are more likely to risk using a new 'less proven' tech stack like Bevy and Rust.

For `DrawOrder` we could have a specific layer system, where a user registers draw layers, and selects from a set of pre-registered draw layers, but this doesn't handle relative positioning. We could handle relative positioning in parent/child relationships by associating some float range and sharing the range between children but many developers would need to manually work around this in order to have children appear on the layers they want (see the existing demand for better control of transform propogation).

In regards to support burden, this support burden must 'be paid' either way, either in the engine, or in the game. Right now we require that burden to be paid by the game developer. I believe it is more correct for that support burden to be paid by the engine and reduce the burden on the game developer.

In regards to the ecosystem effect, right now functionality like this already exists in several crates. There is no standardized solution, and developers would need to know of these possible solutions before starting - something they're unlikely to know given they are unlikely to be embedded in the ecosystem. A standardized in engine solution would improve user experience, improve indie uptake, and - I believe - improve the crate ecosystem by allowing for better targetting of 2D specific crates.

## Unresolved questions

- Do we want to take this burden ourselves?
- Are we happy about using a fairly simple/naive `DrawOrder` implementation?
- What are the knock-on effects on UI and its desire for a potential `UiTransform`/`TransformUi`

## Future possibilities

I have my own crate `rantz_spatial2d` which I'm using in my own game. It's far from perfect and poorly documented, but in it, I have split `Transform2d` into its component parts of `Position2D`, `Rotation2D`, and `Scale2D`. It's out of the scope of this RFC, but often, in 2D games, you actually don't want the whole Transform, only a segment of it, and it may be beneficial to consider splitting `Transform2d` and `Transform` in the same way at some point. Again, outside the scope of this RFC, but an interesting thought.