bevy
bevy copied to clipboard
UI rounding is applied before UI scaling
Bevy version
main 7f658cabf7abd98d52bd94db8bd7fc788f49856a
What you did
Apply any UiScale or window scale factor override over 1, then move a UI element smoothly over the screen.
What went wrong
Here is an example of UiScale being 20 and then moving the white square smoothly across the screen. It jumps by 20 pixel increments. The blue square is there as a comparison to show what smooth movement looks like. GlobalTransform was modified directly to achieve that.
https://github.com/user-attachments/assets/f590e519-95dd-486d-b23d-4e88ffc639de
I would expect the white square position to be rounded after applying UiScale. E.g. Px(0.47) would first be scaled to Px(0.47*20) = Px(9.4), then rounded to Px(9). This would effectively round logical UI pixels to physical screen pixels, which sounds more correct to me than the current behavior.
The jittery movement is especially bad when trying to position scrolling lists or virtual cursors that need to look smooth.
Additional information
Example code used in the video.
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, update)
.run();
}
#[derive(Component)]
struct Node1;
#[derive(Component)]
struct Node2;
#[derive(Resource)]
struct NodeAnimation((f32, f32));
fn setup(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
commands.spawn((
NodeBundle {
background_color: Color::WHITE.into(),
style: Style {
width: Val::Px(6.0),
height: Val::Px(6.0),
position_type: PositionType::Absolute,
..default()
},
..default()
},
Node1,
));
commands.spawn((
NodeBundle {
background_color: Srgba::BLUE.into(),
style: Style {
width: Val::Px(6.0),
height: Val::Px(6.0),
top: Val::Px(6.0),
position_type: PositionType::Absolute,
..default()
},
..default()
},
Node2,
));
commands.insert_resource(UiScale(20.0));
commands.insert_resource(NodeAnimation((0.0, 1.0)));
}
fn cubic_ease_in_out(factor: f32) -> f32 {
if factor < 0.5 {
4. * factor * factor * factor
} else {
1. - (-2. * factor + 2.).powf(3.) / 2.
}
}
fn position(factor: f32) -> f32 {
cubic_ease_in_out(factor) * 30.
}
fn update(
mut node1_q: Query<&mut Style, With<Node1>>,
mut node2_q: Query<&mut GlobalTransform, With<Node2>>,
mut node_animation: ResMut<NodeAnimation>,
time: Res<Time>,
) {
let (factor, direction) = &mut node_animation.0;
*factor += time.delta_seconds() * *direction * 0.3;
if *factor > 1. {
*direction = -1.;
} else if *factor < 0. {
*direction = 1.;
}
for mut style in node1_q.iter_mut() {
style.left = Val::Px(position(*factor));
}
for mut t in node2_q.iter_mut() {
let mut transform = t.compute_transform();
transform.translation.x = position(*factor) + 3.;
*t = transform.into();
}
}
Thanks for the great bug report: I think this explains the weirdness in #14183.
related: https://github.com/bevyengine/bevy/issues/11207
As discussed in #14779, this reverts a previous fix. We need to more carefully think about how and when we apply rounding.
Rounding after scaling is the correct way to do things. That previous fix only works around the floating point imprecision of our rounding method.
We should really only do the rounding once after scaling to pixel coordinates and do the rest of the rendering in pixel coordinates. Now we round back and forth between pixel and logical coordinates and render in logical coordinates. According to discussion in https://github.com/bevyengine/bevy/issues/9754, we could just use Taffy's built in rounding.
Making this happen for all of our UI code is a lot of work though.
In my own Bevy fork I disable all UI rounding to get perfect smoothness and I'm very happy with it. All this rounding business doesn't seem to be essential in all use cases.
Great bug report. It seems like this fix isn't destined to be in 0.15 though :disappointed:
This seems to be fixed by #16097 now 🤔
https://github.com/user-attachments/assets/7eed2b1e-c8a4-4ba8-a5d8-2dfed8396d67
Video lags a bit but both squares move very smoothly in person.