Add ramp-friendly vehicle collision, throttle engine audio and Mixamo SDK guide

This commit is contained in:
chazmm committed 2026-09-08 22:21:17 +01:00
1 parent fb0dd98e83
commit a97c9157e6
16 files changed
+421 -21

No files matched your search

@@ -0,0 +1,10 @@
//! Export the exact native order for an authored vehicle animation package.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 { return Err("Usage: vehicle_bone_names OnBoard.abin bone-names.json".into()); }
let bank = skate_data::abin::Bank::load(std::path::Path::new(&args[0]))?;
let hierarchy = bank.hierarchy().ok_or("Bank has no skeleton hierarchy")?;
std::fs::write(&args[1], serde_json::to_vec_pretty(&hierarchy.bone_names)?)?;
println!("Exported {} bone names", hierarchy.bone_names.len());
Ok(())
}
@@ -0,0 +1,49 @@
//! Original synthesized exhaust sound, shared by all vehicle mods that opt in.
use bevy::{audio::{AddAudioSource, Volume}, prelude::*};
use super::engine_sound::Engine;
#[derive(Component)]
struct EngineVoice;
#[derive(Resource, Default)]
struct State { source: Option<Handle<Engine>>, pitch: f32, volume: f32 }
pub(super) fn install(app: &mut App) {
app.add_audio_source::<Engine>().init_resource::<State>()
.add_systems(Update, update.after(super::present));
}
fn update(
mut commands: Commands, vehicles: Res<super::Vehicles>,
time: Res<Time<Real>>, menu: Option<Res<crate::graphics_menu::Menu>>,
replay: Res<crate::replay::Replay>, mut state: ResMut<State>,
mut sources: ResMut<Assets<Engine>>, mut voices: Query<(Entity, &mut AudioSink), With<EngineVoice>>,
pending: Query<Entity, With<EngineVoice>>,
) {
let car = vehicles.driver.as_ref().and_then(|d| vehicles.owned.get(&(d.owner.clone(), d.key.clone())))
.and_then(|i| vehicles.simulation.vehicles.get(&i.id))
.filter(|c| c.definition.engine_audio.enabled);
let active = crate::graphics_menu::gameplay_active(menu) && !replay.active;
let (pitch, volume) = car.map_or((0.7, 0.), |car| {
let a = &car.definition.engine_audio;
let speed = (car.controller.current_vehicle_speed.abs() / car.definition.max_speed).clamp(0., 1.);
let throttle = car.controls.throttle.abs();
let revs = (speed * 0.65 + throttle * 0.35).clamp(0., 1.);
(a.idle_pitch + (a.max_pitch - a.idle_pitch) * revs,
if active { a.volume * (0.22 + 0.65 * throttle + 0.13 * speed) } else { 0. })
});
let dt = time.delta_secs().min(0.1);
state.pitch += (pitch - state.pitch) * (1. - (-7. * dt).exp());
state.volume += (volume - state.volume) * (1. - (-12. * dt).exp());
if car.is_some() && pending.is_empty() {
let source = state.source.get_or_insert_with(|| sources.add(Engine)).clone();
commands.spawn((EngineVoice, AudioPlayer(source), PlaybackSettings::ONCE
.with_volume(Volume::Linear(0.)).with_speed(pitch)));
state.pitch = pitch;
}
for (entity, mut sink) in &mut voices {
sink.set_speed(state.pitch.max(0.25));
sink.set_volume(Volume::Linear(state.volume));
if car.is_none() && state.volume < 0.001 { commands.entity(entity).despawn(); }
}
}
@@ -0,0 +1,48 @@
//! Original continuous exhaust synthesizer.
use bevy::{audio::Source, prelude::*};
use std::time::Duration;
#[derive(Asset, TypePath)]
pub struct Engine;
pub struct Exhaust { phase: f32, noise: u32, filtered: f32 }
impl Iterator for Exhaust {
type Item = f32;
fn next(&mut self) -> Option<f32> {
self.phase = (self.phase + 80. / 48000.) % 1.;
self.noise = self.noise.wrapping_mul(1664525).wrapping_add(1013904223);
let noise = self.noise as f32 / u32::MAX as f32 * 2. - 1.;
self.filtered += 0.12 * (noise - self.filtered);
let p = self.phase * std::f32::consts::TAU;
let pulse = p.sin() * 0.48 + (p * 2.).sin() * 0.24
+ (p * 3.).sin() * 0.12 + (p * 5.).sin() * 0.06;
Some((pulse + self.filtered * 0.18) * 0.65)
}
}
impl Source for Exhaust {
fn current_frame_len(&self) -> Option<usize> { None }
fn channels(&self) -> u16 { 1 }
fn sample_rate(&self) -> u32 { 48000 }
fn total_duration(&self) -> Option<Duration> { None }
}
impl Decodable for Engine {
type DecoderItem = f32;
type Decoder = Exhaust;
fn decoder(&self) -> Exhaust { Exhaust { phase: 0., noise: 17, filtered: 0. } }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exhaust_is_bounded_continuous_and_not_silent() {
let mut source = Engine.decoder();
let mut last = 0.;
let mut energy = 0.;
for _ in 0..48000 {
let sample = source.next().unwrap();
assert!(sample.is_finite() && sample.abs() < 0.8);
assert!((sample - last).abs() < 0.1);
energy += sample * sample;
last = sample;
}
assert!(energy > 100.);
}
}
@@ -1,5 +1,7 @@
//! Native vehicle host: isolated Rapier world, mod ownership and driver lifecycle.
mod animations;
mod audio;
mod engine_sound;
mod interpolation;
use bevy::prelude::*;
use serde_json::{Value, json};
@@ -77,6 +79,7 @@ impl Vehicles {
}
}
pub(super) fn install(app: &mut App) {
audio::install(app);
app.init_resource::<Vehicles>()
.add_systems(
FixedUpdate,
+35
View File
@@ -8,6 +8,12 @@ pub struct VehicleDefinition {
pub model_offset: [f32; 3],
pub model_yaw: f32,
pub half_extents: [f32; 3],
pub center_of_mass: [f32; 3],
pub inertia_half_extents: Option<[f32; 3]>,
pub collider_offset: [f32; 3],
pub collider_rounding: f32,
pub chassis_friction: f32,
pub engine_audio: EngineAudio,
pub mass: f32,
pub engine_force: f32,
pub brake_impulse: f32,
@@ -24,6 +30,18 @@ pub struct VehicleDefinition {
pub camera_height: f32,
pub animations: Animations,
}
/// Built-in synthesized engine; no external recording is required.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EngineAudio {
pub enabled: bool,
pub volume: f32,
pub idle_pitch: f32,
pub max_pitch: f32,
}
impl Default for EngineAudio {
fn default() -> Self { Self { enabled: false, volume: 0.45, idle_pitch: 0.7, max_pitch: 2.8 } }
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WheelDefinition {
@@ -56,6 +74,12 @@ impl Default for VehicleDefinition {
model_offset: [0.; 3],
model_yaw: 0.,
half_extents: [0.7, 0.2, 1.1],
center_of_mass: [0.; 3],
inertia_half_extents: None,
collider_offset: [0.; 3],
collider_rounding: 0.,
chassis_friction: 0.3,
engine_audio: EngineAudio::default(),
mass: 220.,
engine_force: 1800.,
brake_impulse: 100.,
@@ -96,6 +120,14 @@ impl VehicleDefinition {
|| !point(&self.model_offset, 100.)
|| !self.model_yaw.is_finite()
|| !self.half_extents.iter().all(|x| range(*x, 0.05, 10.))
|| !point(&self.center_of_mass, 10.)
|| self.inertia_half_extents.is_some_and(|v| !v.iter().all(|&x| range(x, 0.05, 10.)))
|| !point(&self.collider_offset, 10.)
|| !range(self.collider_rounding, 0., self.half_extents.iter().copied().fold(f32::INFINITY, f32::min) * 0.95)
|| !range(self.chassis_friction, 0., 2.)
|| !range(self.engine_audio.volume, 0., 1.)
|| !range(self.engine_audio.idle_pitch, 0.25, 2.)
|| !range(self.engine_audio.max_pitch, self.engine_audio.idle_pitch, 5.)
|| !range(self.mass, 10., 10000.)
|| !range(self.engine_force, 0., 100000.)
|| !range(self.brake_impulse, 0., 10000.)
@@ -156,6 +188,7 @@ impl VehicleDefinition {
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct VehicleTuning {
pub engine_volume: Option<f32>,
pub engine_force: Option<f32>,
pub max_speed: Option<f32>,
pub brake_impulse: Option<f32>,
@@ -165,6 +198,7 @@ pub struct VehicleTuning {
impl VehicleTuning {
pub fn apply(&self, definition: &VehicleDefinition) -> Result<VehicleDefinition, String> {
let mut d = definition.clone();
if let Some(v) = self.engine_volume { d.engine_audio.volume = v; }
if let Some(v) = self.engine_force {
d.engine_force = v;
}
@@ -185,6 +219,7 @@ impl VehicleTuning {
}
pub fn valid(&self) -> bool {
[
self.engine_volume.map(|x| (x, 0., 1.)),
self.engine_force.map(|x| (x, 0., 100000.)),
self.max_speed.map(|x| (x, 1., 100.)),
self.brake_impulse.map(|x| (x, 0., 10000.)),
+12 -3
View File
@@ -75,6 +75,15 @@ impl Simulation {
return Err("Invalid vehicle spawn pose".into());
}
let d = &definition;
let r = d.collider_rounding;
let shape = if r > 0. {
ColliderBuilder::round_cuboid(d.half_extents[0]-r, d.half_extents[1]-r, d.half_extents[2]-r, r)
} else {
ColliderBuilder::cuboid(d.half_extents[0], d.half_extents[1], d.half_extents[2])
};
let [x, y, z] = d.inertia_half_extents.unwrap_or(d.half_extents);
let inertia = Vector::new(y*y+z*z, x*x+z*z, x*x+y*y) * (d.mass / 3.);
let mass_properties = MassProperties::new(Vector::from_array(d.center_of_mass) - Vector::from_array(d.collider_offset), d.mass, inertia);
let (body, _) = self.world.insert(
RigidBodyBuilder::dynamic()
.translation(Vector::from_array(position))
@@ -82,9 +91,9 @@ impl Simulation {
.ccd_enabled(true)
.linear_damping(0.08)
.angular_damping(0.5),
ColliderBuilder::cuboid(d.half_extents[0], d.half_extents[1], d.half_extents[2])
.mass(d.mass)
.friction(0.3),
shape.translation(Vector::from_array(d.collider_offset))
.mass_properties(mass_properties)
.friction(d.chassis_friction),
);
let mut controller = DynamicRayCastVehicleController::new(body);
controller.index_up_axis = 1;
+39 -1
View File
@@ -47,7 +47,7 @@ fn kart_suspension_acceleration_braking_reset_and_cleanup() {
for _ in 0..240 {
s.step(1. / 120.);
}
assert!(s.vehicles[&id].controller.current_vehicle_speed.abs() < speed * 0.25);
assert!(s.vehicles[&id].controller.current_vehicle_speed.abs() < speed * 0.25, "brake speed {} initial {speed}, pose {:?}", s.vehicles[&id].controller.current_vehicle_speed, s.pose(id));
s.reset(id, [5., 2., 5.], 1.).unwrap();
assert_eq!(s.pose(id).unwrap().0, [5., 2., 5.]);
s.remove(id);
@@ -144,3 +144,41 @@ fn steering_turns_toward_the_drivers_requested_side() {
}
}
}
#[test]
fn kart_climbs_ramps_without_nose_catching() {
for degrees in [20_f32, 30., 40.] {
let slope = degrees.to_radians().tan();
let mut s = Simulation::default();
let y = 20. * slope;
s.ground([
[[-20.,0.,-30.],[20.,0.,3.],[20.,0.,-30.]],
[[-20.,0.,-30.],[-20.,0.,3.],[20.,0.,3.]],
[[-20.,0.,3.],[20.,y,23.],[20.,0.,3.]],
[[-20.,0.,3.],[-20.,y,23.],[20.,y,23.]],
].into_iter()).unwrap();
let id = s.spawn(definition(), [0.,1.,-2.], 0.).unwrap();
for _ in 0..240 { s.step(1./120.); }
s.vehicles.get_mut(&id).unwrap().controls = Controls { throttle: 1., ..Default::default() };
let mut highest = 0_f32;
let mut forward = 0_f32;
for _ in 0..960 {
s.step(1./120.);
let p=s.pose(id).unwrap().0;
highest=highest.max(p[1]); forward=forward.max(p[2]);
}
println!("Ramp {degrees}: height={highest}, forward={forward}");
assert!(highest > 2. && forward > 7., "failed {degrees} degree ramp: {highest}, {forward}");
}
}
#[test]
fn collision_mass_and_audio_tuning_are_validated() {
let base=definition();
let mut d=base.clone(); d.collider_rounding=0.5; assert!(d.validate().is_err());
let mut d=base.clone(); d.center_of_mass[1]=f32::NAN; assert!(d.validate().is_err());
let mut d=base.clone(); d.inertia_half_extents=Some([0.,1.,1.]); assert!(d.validate().is_err());
let mut d=base.clone(); d.engine_audio.max_pitch=0.1; assert!(d.validate().is_err());
let mute=VehicleTuning { engine_volume:Some(0.), ..Default::default() };
assert!(mute.valid()); assert_eq!(mute.apply(&base).unwrap().engine_audio.volume,0.);
let invalid=VehicleTuning { engine_volume:Some(f32::NAN), ..Default::default() };
assert!(!invalid.valid()); assert!(invalid.apply(&base).is_err());
}