diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index 7c9cc80c..9a28a9e4 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -105,7 +105,8 @@ fn bind( } } fn present( - skater: Res, + history: Res, + time: Res>, animation: Res, mut nodes: Query<&mut Transform>, ) { @@ -116,15 +117,19 @@ fn present( // 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. 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 { - let global = native_matrix(skater.render_pose[binding.bone]) * basis; - let local = if let Some(parent) = binding.parent_bone { - (native_matrix(skater.render_pose[parent]) * basis).inverse() * global - } else { - global + // Blend bone-local rotations, not matrix entries or independent world + // positions: joints retain their hierarchy while limbs turn. + let local = |snapshot: &crate::presentation::Snapshot| { + let global = snapshot.bones[binding.bone] * basis; + 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) { - *transform = Transform::from_matrix(local); + *transform = crate::presentation::blend(local(previous), local(current), alpha); } } } diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index c83a178f..204a19df 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -90,6 +90,7 @@ pub(crate) fn build( .add_plugins(( input::InputPlugin, PhysicsPlugin, + crate::presentation::PresentationPlugin, assets::GameAssetsPlugin, animation::AnimationPlugin, world::WorldPlugin, diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs index ff5fac5b..742e528a 100644 --- a/crates/skate-game/src/camera.rs +++ b/crates/skate-game/src/camera.rs @@ -48,19 +48,17 @@ fn spawn(mut commands: Commands, config: Res) { } fn present(mut runtime: ResMut, windows: Query<&Window>, + history: Res, time: Res>, mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With>) { if let Ok(window) = windows.single() { 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 { - let [right, up, at] = frame.basis.columns.map(Vec3::from_array); - // 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]); + *transform = crate::presentation::blend(previous.camera, current.camera, alpha); 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; } diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 8ff40a0a..b23c9639 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -14,6 +14,7 @@ mod verification; mod performance; mod graphics_menu; mod render_capacity; +mod presentation; mod world; mod skate_world; diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 2033011f..795a4f7b 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -336,15 +336,16 @@ mod difficulty_tests; fn present( physics: Res, - skater: Res, + history: Res, + time: Res>, mut roots: Query<&mut Transform, With>, ) { if physics.failed { return; } + let Some((previous, current)) = history.pair() else { return; }; for mut root in &mut roots { - *root = Transform::from_matrix(crate::animation::native_matrix( - skater.animated_skeleton.roots.animation_to_world, - )); + *root = crate::presentation::blend(previous.root, current.root, + time.overstep_fraction().clamp(0.0, 1.0)); } } diff --git a/crates/skate-game/src/presentation.rs b/crates/skate-game/src/presentation.rs new file mode 100644 index 00000000..2798a901 --- /dev/null +++ b/crates/skate-game/src/presentation.rs @@ -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::() + .add_systems(FixedUpdate, capture.after(SimulationSet::Physics)); + } +} + +#[derive(Clone)] +pub(crate) struct Snapshot { + pub root: Transform, + pub bones: Vec, + 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, camera: Res, + time: Res>, mut history: ResMut) { + 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), + } +} diff --git a/docs/RENDER_INTERPOLATION.md b/docs/RENDER_INTERPOLATION.md new file mode 100644 index 00000000..b6f4a217 --- /dev/null +++ b/docs/RENDER_INTERPOLATION.md @@ -0,0 +1,24 @@ +# Render interpolation + +The Bevy presentation layer retains two completed simulation snapshots and +interpolates them with `Time::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.