Synchronize vehicle rider wheels and camera with fixed-step interpolation

This commit is contained in:
chazmm committed 2026-09-08 22:05:41 +01:00
1 parent 2ef93b2739
commit fb0dd98e83
7 files changed
+109 -24

No files matched your search

+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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
+2 -2
View File
@@ -90,9 +90,9 @@ pub(crate) fn present(vehicles: Res<crate::modding::vehicles::Vehicles>, 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;
}
@@ -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<Transform>,
}
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<Self> {
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);
}
}
}
+35 -18
View File
@@ -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<u64, interpolation::Motion>,
rendered_motion: BTreeMap<u64, interpolation::Motion>,
}
impl Vehicles {
pub(crate) fn occupied(&self) -> bool {
@@ -60,10 +63,10 @@ impl Vehicles {
pub(crate) fn camera(&self) -> Option<Transform> {
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::<Time<Virtual>>().delta_secs();
let alpha=world.resource::<Time<Fixed>>().overstep_fraction();
let phase=world.resource::<Vehicles>().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(&current, 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::<Transform>(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::<Entity, With<crate::world::PlayerRoot>>()
@@ -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();
+3 -1
View File
@@ -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.