Interpolate camera and skater presentation between fixed ticks

This commit is contained in:
chazmm committed 2026-09-06 23:24:16 +01:00
1 parent 12ab6baa68
commit 1c8acbb010
7 files changed
+130 -18

No files matched your search

+12 -7
View File
@@ -105,7 +105,8 @@ fn bind(
} }
} }
fn present( fn present(
skater: Res<SkaterRuntime>, history: Res<crate::presentation::Presentation>,
time: Res<Time<Fixed>>,
animation: Res<AnimationStatus>, animation: Res<AnimationStatus>,
mut nodes: Query<&mut Transform>, mut nodes: Query<&mut Transform>,
) { ) {
@@ -116,15 +117,19 @@ fn present(
// rotated -90 degrees about X relative to the native frames. Both files' // rotated -90 degrees about X relative to the native frames. Both files'
// world positions are Y-up. This is a skin basis change, not a physics turn. // world positions are Y-up. This is a skin basis change, not a physics turn.
let basis = render_basis(); let basis = render_basis();
let Some((previous, current)) = history.pair() else { return; };
let alpha = time.overstep_fraction().clamp(0.0, 1.0);
for binding in &animation.bindings { for binding in &animation.bindings {
let global = native_matrix(skater.render_pose[binding.bone]) * basis; // Blend bone-local rotations, not matrix entries or independent world
let local = if let Some(parent) = binding.parent_bone { // positions: joints retain their hierarchy while limbs turn.
(native_matrix(skater.render_pose[parent]) * basis).inverse() * global let local = |snapshot: &crate::presentation::Snapshot| {
} else { let global = snapshot.bones[binding.bone] * basis;
global Transform::from_matrix(if let Some(parent) = binding.parent_bone {
(snapshot.bones[parent] * basis).inverse() * global
} else { global })
}; };
if let Ok(mut transform) = nodes.get_mut(binding.entity) { if let Ok(mut transform) = nodes.get_mut(binding.entity) {
*transform = Transform::from_matrix(local); *transform = crate::presentation::blend(local(previous), local(current), alpha);
} }
} }
} }
+1
View File
@@ -90,6 +90,7 @@ pub(crate) fn build(
.add_plugins(( .add_plugins((
input::InputPlugin, input::InputPlugin,
PhysicsPlugin, PhysicsPlugin,
crate::presentation::PresentationPlugin,
assets::GameAssetsPlugin, assets::GameAssetsPlugin,
animation::AnimationPlugin, animation::AnimationPlugin,
world::WorldPlugin, world::WorldPlugin,
+5 -7
View File
@@ -48,19 +48,17 @@ fn spawn(mut commands: Commands, config: Res<Config>) {
} }
fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>, fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
history: Res<crate::presentation::Presentation>, time: Res<Time<Fixed>>,
mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With<GameplayCamera>>) { mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With<GameplayCamera>>) {
if let Ok(window) = windows.single() { if let Ok(window) = windows.single() {
runtime.set_aspect_ratio(window.width() / window.height()); runtime.set_aspect_ratio(window.width() / window.height());
} }
let Some(frame) = runtime.frame else { return; }; let Some((previous, current)) = history.pair() else { return; };
let alpha = time.overstep_fraction().clamp(0.0, 1.0);
for (mut camera, mut transform, mut projection) in &mut cameras { for (mut camera, mut transform, mut projection) in &mut cameras {
let [right, up, at] = frame.basis.columns.map(Vec3::from_array); *transform = crate::presentation::blend(previous.camera, current.camera, alpha);
// World rendering retains nativeXYZ. Native camera At points forward;
// Bevy camera looks along local-Z, hence the two-axis basis conversion.
transform.rotation = Quat::from_mat3(&Mat3::from_cols(-right, up, -at));
transform.translation = Vec3::new(frame.position[0], frame.position[1], frame.position[2]);
if let Projection::Perspective(p) = &mut *projection { if let Projection::Perspective(p) = &mut *projection {
p.fov = frame.field_of_view_degrees.to_radians(); p.fov = previous.fov + (current.fov - previous.fov) * alpha;
} }
camera.is_active = true; camera.is_active = true;
} }
+1
View File
@@ -14,6 +14,7 @@ mod verification;
mod performance; mod performance;
mod graphics_menu; mod graphics_menu;
mod render_capacity; mod render_capacity;
mod presentation;
mod world; mod world;
mod skate_world; mod skate_world;
+5 -4
View File
@@ -336,15 +336,16 @@ mod difficulty_tests;
fn present( fn present(
physics: Res<GamePhysics>, physics: Res<GamePhysics>,
skater: Res<SkaterRuntime>, history: Res<crate::presentation::Presentation>,
time: Res<Time<Fixed>>,
mut roots: Query<&mut Transform, With<PlayerRoot>>, mut roots: Query<&mut Transform, With<PlayerRoot>>,
) { ) {
if physics.failed { if physics.failed {
return; return;
} }
let Some((previous, current)) = history.pair() else { return; };
for mut root in &mut roots { for mut root in &mut roots {
*root = Transform::from_matrix(crate::animation::native_matrix( *root = crate::presentation::blend(previous.root, current.root,
skater.animated_skeleton.roots.animation_to_world, time.overstep_fraction().clamp(0.0, 1.0));
));
} }
} }
+82
View File
@@ -0,0 +1,82 @@
//! Render-only snapshots of completed fixed ticks. Camera, root and skin use
//! the same interval; interpolated values never feed back into simulation.
use bevy::prelude::*;
use crate::{app::SimulationSet, camera::CameraRuntime, physics::SkaterRuntime};
use skate_core::camera::CameraFrame;
pub(crate) struct PresentationPlugin;
impl Plugin for PresentationPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Presentation>()
.add_systems(FixedUpdate, capture.after(SimulationSet::Physics));
}
}
#[derive(Clone)]
pub(crate) struct Snapshot {
pub root: Transform,
pub bones: Vec<Mat4>,
pub camera: Transform,
pub fov: f32,
}
#[derive(Resource, Default)]
pub(crate) struct Presentation {
pair: Option<(Snapshot, Snapshot)>,
generation: u64,
period: std::time::Duration,
}
impl Presentation {
pub fn pair(&self) -> Option<(&Snapshot, &Snapshot)> {
self.pair.as_ref().map(|(a, b)| (a, b))
}
}
fn capture(skater: Res<SkaterRuntime>, camera: Res<CameraRuntime>,
time: Res<Time<Fixed>>, mut history: ResMut<Presentation>) {
if skater.pose_generation == history.generation { return; }
let Some(frame) = camera.frame else { return; };
let next = Snapshot {
root: Transform::from_matrix(crate::animation::native_matrix(
skater.animated_skeleton.roots.animation_to_world)),
bones: skater.render_pose.iter().copied()
.map(crate::animation::native_matrix).collect(),
camera: camera_transform(frame),
fov: frame.field_of_view_degrees.to_radians(),
};
// Snap teleports/cuts and cadence changes instead of sweeping through the
// world or blending snapshots across different simulation-clock periods.
let reset = frame.discontinuity
|| skater.player_input.processed.flags_2472 & (1 << 10) != 0
|| history.period != time.timestep();
if let Some((previous, current)) = history.pair.as_mut() {
if reset || current.bones.len() != next.bones.len() {
*previous = next.clone();
} else {
std::mem::swap(previous, current);
}
*current = next;
} else {
history.pair = Some((next.clone(), next));
}
history.generation = skater.pose_generation;
history.period = time.timestep();
}
fn camera_transform(frame: CameraFrame) -> Transform {
let [right, up, at] = frame.basis.columns.map(Vec3::from_array);
Transform {
translation: Vec3::new(frame.position[0], frame.position[1], frame.position[2]),
// Native At is forward; Bevy cameras look along local -Z.
rotation: Quat::from_mat3(&Mat3::from_cols(-right, up, -at)).normalize(),
..default()
}
}
pub(crate) fn blend(a: Transform, b: Transform, alpha: f32) -> Transform {
Transform {
translation: a.translation.lerp(b.translation, alpha),
rotation: a.rotation.normalize().slerp(b.rotation.normalize(), alpha),
scale: a.scale.lerp(b.scale, alpha),
}
}
+24
View File
@@ -0,0 +1,24 @@
# Render interpolation
The Bevy presentation layer retains two completed simulation snapshots and
interpolates them with `Time<Fixed>::overstep_fraction()` in `Update`. Capture
runs after the physics system on every fixed tick, including catch-up ticks.
Frames without a new simulation tick still advance the interpolation fraction.
Snapshots contain the animation root, final physical skin hierarchy (including
the board/trucks/wheels), and camera transform/FOV. Bone transforms are converted
to skin-local transforms at each endpoint before translation/scale lerp and
quaternion slerp. The root and camera use that same fraction. These rendered
transforms are not read back by the native simulation or camera controllers.
Initialization, camera discontinuities, the existing camera-reset flag and
simulation-period changes collapse the history to the newest snapshot. There
is no boxcar camera filter, additional smoothing window, or extrapolation.
Interpolation introduces one fixed interval of presentation delay (normally
about 16.7 ms). This is a renderer feature, not a claim about retail behavior.
Reference: Bevy's official fixed-timestep presentation example:
https://bevy.org/examples-webgpu/movement/physics-in-fixed-timestep/
Validation: `Build.ps1` succeeded; no automated or gameplay tests were run,
as requested. Gameplay smoothness remains for the user to assess.