diff --git a/Build-VehicleSDK.ps1 b/Build-VehicleSDK.ps1 index 948d351f..f71943be 100644 --- a/Build-VehicleSDK.ps1 +++ b/Build-VehicleSDK.ps1 @@ -3,7 +3,7 @@ $ErrorActionPreference = 'Stop' $privateDirectory = Join-Path $PSScriptRoot '.local/vehicle-sdk' $buildDirectory = Join-Path $privateDirectory 'build' $artifact = Join-Path $buildDirectory 'skate3rust-vehicle-sdk.exe' -$destination = Join-Path $privateDirectory 'skate3-vehicle-animations.exe' +$destination = Join-Path $privateDirectory 'skate3-vehicle-smooth.exe' Push-Location $PSScriptRoot $previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS try { diff --git a/PLAY-MARIO-KART.bat b/PLAY-MARIO-KART.bat index 67d879dd..7b70b6fa 100644 --- a/PLAY-MARIO-KART.bat +++ b/PLAY-MARIO-KART.bat @@ -9,7 +9,7 @@ if not defined SKATE3_ASSETS ( pause exit /b 1 ) -if not exist "%~dp0.local\vehicle-sdk\skate3-vehicle-animations.exe" ( +if not exist "%~dp0.local\vehicle-sdk\skate3-vehicle-smooth.exe" ( echo Build missing. Run Build-VehicleSDK.ps1 first. pause exit /b 1 @@ -20,5 +20,5 @@ echo Open Mods and ENABLE Mario Kart. F10 spawns the kart; E enters/exits. Escap echo WASD drive, Space brake, Shift handbrake, R reset. Slow down before exiting. echo Mods folder: %~dp0.local\vehicle-sdk\mods echo Trainer log: %~dp0.local\vehicle-sdk\session.log -"%~dp0.local\vehicle-sdk\skate3-vehicle-animations.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1 +"%~dp0.local\vehicle-sdk\skate3-vehicle-smooth.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1 if errorlevel 1 pause diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs index f177dfa8..dea117c3 100644 --- a/crates/skate-game/src/camera.rs +++ b/crates/skate-game/src/camera.rs @@ -90,9 +90,9 @@ pub(crate) fn present(vehicles: Res, mut run if vehicle_blend.elapsed<0.5 && from.translation.distance(transform.translation)<50. { let t=(vehicle_blend.elapsed/0.5).clamp(0.,1.);*transform=crate::presentation::blend(from,*transform,t*t*(3.-2.*t)); } else {vehicle_blend.from=None;} - } else if active { - if let Some(previous)=vehicle_blend.previous {*transform=crate::presentation::blend(previous,*transform,1.-(-18.*virtual_time.delta_secs()).exp());} } + // Vehicle motion is already interpolated with the rider/chassis. A separate + // follow filter introduces relative motion and makes fixed ticks visible. vehicle_blend.previous=Some(*transform); camera.is_active = true; } diff --git a/crates/skate-game/src/modding/vehicles/interpolation.rs b/crates/skate-game/src/modding/vehicles/interpolation.rs new file mode 100644 index 00000000..1b6dc21d --- /dev/null +++ b/crates/skate-game/src/modding/vehicles/interpolation.rs @@ -0,0 +1,65 @@ +//! One render-time sample for every visible part of a fixed-step vehicle. +use bevy::prelude::*; + +#[derive(Clone)] +pub(super) struct Motion { + pub body: Transform, + pub wheels: Vec, +} + +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), + } +} + +impl Motion { + pub fn capture(sim: &skate_vehicles::Simulation, id: u64) -> Option { + let (p, q) = sim.pose(id)?; + let car = sim.vehicles.get(&id)?; + Some(Self { + body: Transform::from_translation(Vec3::from_array(p)) + .with_rotation(Quat::from_array(q)), + wheels: car.controller.wheels().iter().map(|wheel| { + Transform::from_xyz(0., (car.definition.suspension_length + - wheel.raycast_info().suspension_length) / car.definition.model_scale, 0.) + .with_rotation(Quat::from_rotation_y(wheel.steering) + * Quat::from_rotation_x(-wheel.rotation)) + }).collect(), + }) + } + + pub fn sample(&self, current: &Self, alpha: f32) -> Self { + let alpha = alpha.clamp(0., 1.); + Self { + body: blend(self.body, current.body, alpha), + wheels: current.wheels.iter().enumerate().map(|(i, &now)| { + self.wheels.get(i).map_or(now, |&before| blend(before, now, alpha)) + }).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn subframes_move_continuously_and_keep_seat_attached() { + let before = Motion { body: Transform::IDENTITY, wheels: vec![Transform::IDENTITY] }; + let after = Motion { + body: Transform::from_xyz(1., 0., 0.).with_rotation(Quat::from_rotation_y(0.2)), + wheels: vec![Transform::from_xyz(0., 0.1, 0.)], + }; + let seat = Vec3::new(0., 0.18, -0.2); + for index in 0..=4 { + let alpha = index as f32 / 4.; + let sample = before.sample(&after, alpha); + assert!((sample.body.translation.x - alpha).abs() < 1e-6); + assert!((sample.wheels[0].translation.y - alpha * 0.1).abs() < 1e-6); + let rider = sample.body.transform_point(seat); + assert!((rider.distance(sample.body.translation) - seat.length()).abs() < 1e-6); + } + } +} diff --git a/crates/skate-game/src/modding/vehicles/mod.rs b/crates/skate-game/src/modding/vehicles/mod.rs index a8677b2e..b560c4b8 100644 --- a/crates/skate-game/src/modding/vehicles/mod.rs +++ b/crates/skate-game/src/modding/vehicles/mod.rs @@ -1,5 +1,6 @@ //! Native vehicle host: isolated Rapier world, mod ownership and driver lifecycle. mod animations; +mod interpolation; use bevy::prelude::*; use serde_json::{Value, json}; use skate_mods::Command; @@ -40,6 +41,8 @@ pub(crate) struct Vehicles { visual_phase: String, blend_time: f32, steering_visual: f32, + previous_motion: BTreeMap, + rendered_motion: BTreeMap, } impl Vehicles { pub(crate) fn occupied(&self) -> bool { @@ -60,10 +63,10 @@ impl Vehicles { pub(crate) fn camera(&self) -> Option { let d = self.driver.as_ref()?; let i = self.owned.get(&(d.owner.clone(), d.key.clone()))?; - let (p, q) = self.simulation.pose(i.id)?; - let q = Quat::from_array(q); + let pose = self.rendered_motion.get(&i.id)?.body; + let q = pose.rotation; let def = &self.simulation.vehicles[&i.id].definition; - let center = Vec3::from_array(p) + Vec3::Y * 0.5; + let center = pose.translation + Vec3::Y * 0.5; let forward = (q * Vec3::Z).with_y(0.).normalize_or_zero(); Some( Transform::from_translation( @@ -165,6 +168,8 @@ pub(super) fn retire(world: &mut World, owner: &str) { for key in keys { if let Some(i) = v.owned.remove(&key) { v.simulation.remove(i.id); + v.previous_motion.remove(&i.id); + v.rendered_motion.remove(&i.id); world.despawn(i.entity); } } @@ -184,6 +189,8 @@ pub(super) fn clear(world: &mut World) { v.driver = None; v.pose = None; v.last_visual.clear();v.blend_from.clear();v.visual_phase.clear();v.steering_visual=0.; + v.previous_motion.clear(); + v.rendered_motion.clear(); v.simulation = Simulation::default(); v.events.clear(); }); @@ -309,6 +316,8 @@ pub(super) fn command( } if let Some(i) = v.owned.remove(&owned_key) { v.simulation.remove(i.id); + v.previous_motion.remove(&i.id); + v.rendered_motion.remove(&i.id); world.despawn(i.entity); event(&mut v, owner, &key, "vehicle_removed"); } @@ -398,6 +407,8 @@ pub(super) fn command( let i = v.owned.get(&owned_key).ok_or("Unknown vehicle")?; let id = i.id; v.simulation.reset(id, position, heading)?; + v.previous_motion.remove(&id); + v.rendered_motion.remove(&id); event(&mut v, owner, &key, "vehicle_reset"); } _ => unreachable!(), @@ -460,6 +471,8 @@ fn tick(world: &mut World) { } } if !v.owned.is_empty() { + v.previous_motion = v.simulation.vehicles.keys().filter_map(|&id| + interpolation::Motion::capture(&v.simulation, id).map(|m| (id, m))).collect(); v.simulation.step(dt); } if let Some((owner, key, phase)) = driver_info { @@ -491,6 +504,7 @@ fn tick(world: &mut World) { } pub(crate) fn present(world: &mut World) { let dt=world.resource::>().delta_secs(); + let alpha=world.resource::>().overstep_fraction(); let phase=world.resource::().driver.as_ref().map_or("vanilla",|d|d.phase).to_owned(); let current=crate::animation::capture_vehicle_visual(world); { @@ -524,11 +538,17 @@ pub(crate) fn present(world: &mut World) { .manager .fail(&owner, error); } + // Chassis, wheels, rider and camera share one fixed-step render sample. + v.rendered_motion = v.simulation.vehicles.keys().filter_map(|&id| { + let current = interpolation::Motion::capture(&v.simulation, id)?; + let sample = v.previous_motion.get(&id).map_or_else(|| current.clone(), + |previous| previous.sample(¤t, alpha)); + Some((id, sample)) + }).collect(); for i in v.owned.values() { - if let Some((p, q)) = v.simulation.pose(i.id) { + if let Some(sample) = v.rendered_motion.get(&i.id) { if let Some(mut t) = world.get_mut::(i.entity) { - *t = Transform::from_translation(Vec3::from_array(p)) - .with_rotation(Quat::from_array(q)); + *t = sample.body; } } } @@ -582,15 +602,12 @@ pub(crate) fn present(world: &mut World) { .query::<(&WheelVisual, &mut Transform)>() .iter_mut(world) { - if let Some(car) = v.simulation.vehicles.get(&wheel.vehicle) { - let physical = &car.controller.wheels()[wheel.index]; - *transform = wheel.rest; - transform.translation.y += (car.definition.suspension_length - - physical.raycast_info().suspension_length) - / car.definition.model_scale; - transform.rotation = wheel.rest.rotation - * Quat::from_rotation_y(physical.steering) - * Quat::from_rotation_x(-physical.rotation); + if let Some(sample) = v.rendered_motion.get(&wheel.vehicle) { + if let Some(offset) = sample.wheels.get(wheel.index) { + *transform = wheel.rest; + transform.translation += offset.translation; + transform.rotation = wheel.rest.rotation * offset.rotation; + } } } v.pose = None; @@ -622,9 +639,9 @@ pub(crate) fn present(world: &mut World) { let steering=v.steering_visual+(target_steering-v.steering_visual)*(1.-(-12.*dt).exp()); let pose = i.clips.pose(name, d.time, d.phase == "driving"); let turn=if d.phase=="driving" {i.clips.pose(if steering>=0. {a.steer_left.as_ref()} else {a.steer_right.as_ref()},d.time,true)} else {None}; - let (p, q) = v.simulation.pose(i.id).unwrap(); - let q = Quat::from_array(q); - let seat = Vec3::from_array(p) + q * Vec3::from_array(car.definition.seat); + let body = v.rendered_motion[&i.id].body; + let q = body.rotation; + let seat = body.translation + q * Vec3::from_array(car.definition.seat); v.steering_visual=steering; let roots: Vec<_> = world .query_filtered::>() diff --git a/crates/skate-vehicles/tests/simulation.rs b/crates/skate-vehicles/tests/simulation.rs index 044e0c45..fe3e75e8 100644 --- a/crates/skate-vehicles/tests/simulation.rs +++ b/crates/skate-vehicles/tests/simulation.rs @@ -63,6 +63,7 @@ fn definitions_and_controls_reject_invalid_inputs() { d.model = "../outside.glb".into(); assert!(d.validate().is_err()); let mut d = definition(); + d.animations.file = None; d.animations.drive = Some("drive".into()); assert!(d.validate().is_err()); let mut d = definition(); diff --git a/docs/vehicle-sdk.md b/docs/vehicle-sdk.md index 7831428a..868e0914 100644 --- a/docs/vehicle-sdk.md +++ b/docs/vehicle-sdk.md @@ -166,7 +166,9 @@ The host blends vehicle phase changes over 0.4 seconds and the return to vanilla 0.5 seconds, using the game's bone-local translation/scale lerp and quaternion slerp. Steering input is smoothed and blends the current driving pose towards steer_left or steer_right continuously, so these slots should contain compatible seated poses. -Vehicle camera hand-offs ease over 0.5 seconds; the follow camera is damped while driving. +Vehicle camera hand-offs ease over 0.5 seconds. While driving, chassis, wheels, rider +and camera share the same interpolated fixed-step motion sample, avoiding relative +jitter from separate camera damping. Resets discard the old motion sample. The board root is scaled away during vehicle playback and restored by the vanilla blend. The local Mario Kart package supplies fitted entry/exit, seated and steering clips; its proprietary rider.json is generated locally and is not included in source control.