Add generic mesh and collider deformation with custom difficulty controls

This commit is contained in:
Ethanw05 committed 2026-09-29 20:19:36 -06:00
1 parent ddc30284b4
commit dfd3e543c0
37 files changed
+1330 -49

No files matched your search

+18 -3
View File
@@ -2,14 +2,14 @@
use serde::Deserialize;
use std::{collections::BTreeMap, path::Path};
#[derive(Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize)]
pub struct Field {
#[serde(rename = "type")]
pub type_name: String,
pub data: String,
}
#[derive(Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize)]
pub struct Collection {
#[serde(rename = "class")]
pub class_name: String,
@@ -20,13 +20,28 @@ pub struct Collection {
pub sha256: String,
}
#[derive(Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize)]
pub struct Collections {
version: u32,
collections: Vec<Collection>,
}
impl Collections {
/// Replace one profile with a child of a base profile, without editing assets.
pub fn override_profile(&mut self, class: &str, target: &str, base: &str,
fields: BTreeMap<String, Field>) -> Result<(), String> {
let id = crate::attrib_hash::numeric_name(class);
let base_id = crate::attrib_hash::numeric_name(base);
if !self.collections.iter().any(|c| crate::attrib_hash::numeric_name(&c.class_name)==id
&& crate::attrib_hash::numeric_name(&c.key)==base_id) { return Err("Missing base profile".into()); }
let target_id = crate::attrib_hash::numeric_name(target);
self.collections.retain(|c| !(crate::attrib_hash::numeric_name(&c.class_name)==id
&& crate::attrib_hash::numeric_name(&c.key)==target_id));
self.collections.push(Collection {class_name:class.into(),key:target.into(),parent:base.into(),
fields,source:"host custom profile".into(),sha256:String::new()});
Ok(())
}
pub fn entries(&self) -> &[Collection] {
&self.collections
}
+16 -3
View File
@@ -2,7 +2,7 @@
//! Scalar metadata is JSON; hull vertices are lossless little-endian f32 values.
//! This is an in-memory wire encoding, NOT a collision JSON/file requirement.
use crate::{BodyDefinition, Shape, model::{MAX_BODY_POINTS, MAX_COMPOUND_HULLS, MAX_HULL_POINTS}};
const MAGIC: &[u8; 4] = b"SBD3";
const MAGIC: &[u8; 4] = b"SBD4";
const MAX_HEADER: usize = 8_192;
pub const MAX_DEFINITION_BYTES: usize = 640 * 192;
@@ -38,6 +38,9 @@ pub fn encode(definition: &BodyDefinition) -> Result<Vec<u8>, String> {
groups.push(std::mem::take(points));
Ok(())
})?;
let offsets=if let Some(field)=&mut header.deformation {
field.validate()?; std::mem::take(&mut field.offsets)
} else {Vec::new()};
let metadata = serde_json::to_vec(&header).map_err(|e| e.to_string())?;
if metadata.len() > MAX_HEADER { return Err("body definition metadata budget exceeded".into()); }
let mut bytes = Vec::with_capacity(8 + metadata.len() + 12 * total + 2 * groups.len());
@@ -48,6 +51,7 @@ pub fn encode(definition: &BodyDefinition) -> Result<Vec<u8>, String> {
bytes.extend_from_slice(&(points.len() as u16).to_le_bytes());
for point in points { for value in point { bytes.extend_from_slice(&value.to_le_bytes()); } }
}
for offset in offsets {for value in offset {bytes.extend_from_slice(&value.to_le_bytes());}}
if bytes.len() > MAX_DEFINITION_BYTES { return Err("body definition byte budget exceeded".into()); }
Ok(bytes)
}
@@ -83,6 +87,15 @@ pub fn decode(bytes: &[u8]) -> Result<BodyDefinition, String> {
at += payload.len();
Ok(())
})?;
if let Some(field)=&mut definition.deformation {
if !field.offsets.is_empty() || field.resolution.iter().any(|n| !(2..=25).contains(n)) {return Err("bad deformation header".into());}
let count=field.resolution.iter().product::<usize>();
if count>2048 {return Err("deformation field budget exceeded".into());}
let payload=bytes.get(at..at+count*12).ok_or("truncated deformation field")?;
field.offsets=payload.chunks_exact(12).map(|p|std::array::from_fn(|a|
f32::from_le_bytes(p[a*4..a*4+4].try_into().unwrap()))).collect();
field.validate()?; at+=payload.len();
}
if at != bytes.len() { return Err("trailing body definition bytes".into()); }
Ok(definition)
}
@@ -94,7 +107,7 @@ mod tests {
#[test]
fn compound_round_trip_and_truncation() {
let hull = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]];
let definition = BodyDefinition { body: BodyDesc {
let definition = BodyDefinition { deformation: None, body: BodyDesc {
shape: Shape::Compound { hulls: vec![hull.clone(), hull] }, ..Default::default()
}, extras: vec![] };
let bytes = encode(&definition).unwrap();
@@ -106,7 +119,7 @@ mod tests {
}
#[test]
fn rejects_asset_paths_and_oversized_data() {
let definition = BodyDefinition { body: BodyDesc { shape: Shape::Model {
let definition = BodyDefinition { deformation: None, body: BodyDesc { shape: Shape::Model {
path: "model.glb".into(), object: String::new(), options: Default::default()
}, ..Default::default() }, extras: vec![] };
assert!(encode(&definition).is_err());
+416
View File
@@ -0,0 +1,416 @@
//! Bounded, impact-driven plasticity shared by render meshes and solid colliders.
//! A low-resolution material lattice is sampled against the actual obstacle.
//! Resting bodies have no deformation work; no per-frame mesh uploads or VHACD.
use super::*;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Options {
/// Plastic yield: contact impulse divided by this body's mass, in m/s.
pub yield_speed: f32,
/// Metres of permanent displacement per m/s above yield.
pub compliance: f32,
pub radius: f32,
pub max_displacement: f32,
pub max_step: f32,
pub cooldown: f32,
pub resolution: [usize; 3],
}
impl Default for Options {
fn default() -> Self {
Self { yield_speed: 2., compliance: 0.045, radius: 1.2,
max_displacement: 0.55, max_step: 0.22, cooldown: 0.10,
resolution: [9, 5, 17] }
}
}
impl Options {
pub fn validate(&self) -> Result<(), String> {
if !self.yield_speed.is_finite() || !(0.5..=100.).contains(&self.yield_speed)
|| !self.compliance.is_finite() || !(0.001..=0.2).contains(&self.compliance)
|| !self.radius.is_finite() || !(0.1..=5.).contains(&self.radius)
|| !self.max_displacement.is_finite() || !(0.01..=2.).contains(&self.max_displacement)
|| !self.max_step.is_finite() || !(0.001..=self.max_displacement).contains(&self.max_step)
|| !self.cooldown.is_finite() || !(0.05..=2.).contains(&self.cooldown)
|| self.resolution.iter().any(|n| !(2..=25).contains(n))
|| self.resolution.iter().product::<usize>() > 2048 {
return Err("invalid bounded deformation options".into());
}
Ok(())
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Field {
pub min: [f32; 3],
pub max: [f32; 3],
pub resolution: [usize; 3],
pub offsets: Vec<[f32; 3]>,
pub revision: u64,
}
impl Field {
pub fn validate(&self) -> Result<(), String> {
if self.resolution.iter().any(|n| !(2..=25).contains(n)) {
return Err("invalid deformation resolution".into());
}
let count = self.resolution.iter().product::<usize>();
if count > 2048 || self.offsets.len() != count
|| self.min.iter().chain(&self.max).any(|v| !v.is_finite() || v.abs() > 1000.)
|| (0..3).any(|i| self.max[i] - self.min[i] < 0.001)
|| self.offsets.iter().flatten().any(|v| !v.is_finite() || v.abs() > 2.) {
return Err("invalid deformation field".into());
}
Ok(())
}
fn index(&self, xyz: [usize; 3]) -> usize {
(xyz[2] * self.resolution[1] + xyz[1]) * self.resolution[0] + xyz[0]
}
pub fn rest(&self, i: usize) -> Vector {
let [nx,ny,_] = self.resolution;
let xyz = [i % nx, (i / nx) % ny, i / (nx * ny)];
Vector::from_array(std::array::from_fn(|a| self.min[a]
+ (self.max[a]-self.min[a]) * xyz[a] as f32 / (self.resolution[a]-1) as f32))
}
/// Cached once per render vertex; no searches or Lua tables during updates.
pub fn weights(&self, p: [f32; 3]) -> ([usize; 8], [f32; 8]) {
let v: [f32; 3] = std::array::from_fn(|a| ((p[a]-self.min[a]) / (self.max[a]-self.min[a]))
.clamp(0.,1.) * (self.resolution[a]-1) as f32);
let base: [usize;3] = std::array::from_fn(|a| (v[a] as usize).min(self.resolution[a]-2));
let frac: [f32;3] = std::array::from_fn(|a| v[a]-base[a] as f32);
let ids = std::array::from_fn(|i| self.index(std::array::from_fn(|a| base[a]+((i>>a)&1))));
let weights = std::array::from_fn(|i| (0..3).map(|a| if (i>>a)&1==0 {1.-frac[a]} else {frac[a]}).product());
(ids,weights)
}
pub fn displacement(&self, p: [f32;3]) -> Vector {
let (ids, weights)=self.weights(p);
(0..8).fold(Vector::ZERO, |v,i| v + Vector::from_array(self.offsets[ids[i]])*weights[i])
}
/// Radial falloff bounds the work, but only rays hitting actual obstacle
/// geometry cause displacement. A narrow post cannot produce a wall dent.
fn dent(&mut self, pose: Pose, point: Vector, inward: Vector, depth: f32,
obstacle: &Collider, options: &Options) -> bool {
let outward = -inward;
let local_inward = pose.rotation.inverse() * inward;
let mut changed = false;
for i in 0..self.offsets.len() {
let old = Vector::from_array(self.offsets[i]);
let current = pose * (self.rest(i) + old);
let delta = current-point;
let lateral = delta - inward*delta.dot(inward);
let distance = lateral.length()/options.radius;
if distance >= 1. || delta.dot(inward).abs() > options.radius { continue; }
let ray = Ray::new(current - outward*options.radius, outward);
let Some(hit) = obstacle.shape().cast_ray(obstacle.position(), &ray, options.radius+depth, true) else {continue};
let amount = (options.radius + depth - hit).clamp(0., depth)
* (1.-distance*distance).powi(2);
if amount < 0.0005 {continue;}
let next = (old + local_inward*amount).clamp_length_max(options.max_displacement);
if (next-old).length_squared() > 0.000_000_25 {
self.offsets[i] = next.to_array(); changed = true;
}
}
changed
}
}
#[derive(Clone)]
pub(super) struct Runtime {
options: Options,
pub field: Field,
rest: Vec<Vec<[f32;3]>>,
current: Vec<Vec<[f32;3]>>,
shapes: Vec<SharedShape>,
remaining: f32,
}
impl Runtime {
pub(super) fn replica(field:Field, options:Options) -> Result<Self,String> {
options.validate()?;field.validate()?;
if field.resolution!=options.resolution {return Err("deformation resolution mismatch".into());}
Ok(Self {options,field,rest:Vec::new(),current:Vec::new(),shapes:Vec::new(),remaining:0.})
}
pub fn new(desc: &BodyDesc, options: Options) -> Result<Self,String> {
options.validate()?;
if desc.sensor {return Err("sensors cannot deform".into());}
type Templates=Vec<(Shape,f32,[usize;3],Runtime)>;
static CACHE:std::sync::OnceLock<std::sync::Mutex<Templates>>=std::sync::OnceLock::new();
let cache=CACHE.get_or_init(Default::default);
if let Ok(entries)=cache.lock() {
if let Some((_,_,_,template))=entries.iter().find(|(shape,radius,res,_)|
shape==&desc.shape && *radius==options.radius && *res==options.resolution) {
let mut runtime=template.clone();runtime.options=options;return Ok(runtime);
}
}
let mut rest = match &desc.shape {
Shape::Compound {hulls} => hulls.clone(),
Shape::Convex {points} => vec![points.clone()],
Shape::Box {half_extents:h} => vec![(0..8).map(|i|
std::array::from_fn(|a| h[a] * if (i>>a)&1==0 {-1.} else {1.})).collect()],
_ => return Err("deformation requires box, convex or resolved compound geometry".into()),
};
// Refine only at creation. Large convex pieces otherwise bridge across
// a localized concave dent. Plane splits preserve the original solid and
// give the damaged compound enough local support, without runtime VHACD.
refine(&mut rest, options.radius * 0.5);
let mut min = Vector::splat(f32::MAX);
let mut max = Vector::splat(-f32::MAX);
for p in rest.iter().flatten() {let p=Vector::from_array(*p); min=min.min(p);max=max.max(p);}
if (max-min).min_element()<0.001 {return Err("deformation requires volumetric geometry".into());}
let count=options.resolution.iter().product();
let field=Field {min:min.to_array(),max:max.to_array(),resolution:options.resolution,
offsets:vec![[0.;3];count],revision:0};
let shapes = rest.iter().map(|points| SharedShape::convex_hull(&points.iter().copied()
.map(Vector::from_array).collect::<Vec<_>>()).ok_or("invalid deformable hull".to_owned()))
.collect::<Result<Vec<_>,_>>()?;
let runtime=Self {options,field,current:rest.clone(),rest,shapes,remaining:0.};
if let Ok(mut entries)=cache.lock() {
if entries.len()>=8 {entries.remove(0);}
entries.push((desc.shape.clone(),runtime.options.radius,runtime.options.resolution,runtime.clone()));
}
Ok(runtime)
}
fn rebuild(&self, field: &Field) -> Result<(Vec<Vec<[f32;3]>>,Vec<SharedShape>,SharedShape),String> {
let mut hulls=Vec::with_capacity(self.rest.len());
let mut shapes=self.shapes.clone();
for (i,rest) in self.rest.iter().enumerate() {
let points:Vec<_>=rest.iter().map(|p| (Vector::from_array(*p)+field.displacement(*p)).to_array()).collect();
if points.iter().zip(&self.current[i]).any(|(a,b)| Vector::from_array(*a).distance_squared(Vector::from_array(*b))>1e-10) {
shapes[i]=SharedShape::convex_hull(&points.iter().copied().map(Vector::from_array).collect::<Vec<_>>())
.ok_or("deformation collapsed a convex part")?;
}
hulls.push(points);
}
let compound=SharedShape::compound(shapes.iter().cloned().map(|s|(Pose::identity(),s)).collect());
Ok((hulls,shapes,compound))
}
}
fn refine(hulls:&mut Vec<Vec<[f32;3]>>, target:f32) {
let mut exhausted=std::collections::BTreeSet::new();
while hulls.len()<model::MAX_COMPOUND_HULLS {
let candidate=hulls.iter().enumerate().filter(|(i,_)|!exhausted.contains(i)).filter_map(|(i,p)| {
let mut lo=Vector::splat(f32::MAX);let mut hi=-lo;
for &v in p {let v=Vector::from_array(v);lo=lo.min(v);hi=hi.max(v);}
let size=hi-lo;
let axis=(0..3).max_by(|&a,&b|size[a].total_cmp(&size[b]))?;
(size[axis]>target).then_some((i,axis,(lo[axis]+hi[axis])*0.5,size[axis]))
}).max_by(|a,b|a.3.total_cmp(&b.3));
let Some((i,axis,cut,_))=candidate else {break};
let Some(shape)=SharedShape::convex_hull(&hulls[i].iter().copied().map(Vector::from_array).collect::<Vec<_>>()) else {exhausted.insert(i);continue};
let Some(poly)=shape.as_convex_polyhedron() else {exhausted.insert(i);continue};
let (vertices,triangles)=poly.to_trimesh();
let mut sides:[Vec<Vector>;2]=std::array::from_fn(|side|vertices.iter().copied().filter(|p|
if side==0 {p[axis]<=cut} else {p[axis]>=cut}).collect());
let mut edges=std::collections::BTreeSet::new();
for tri in triangles {for (a,b) in [(tri[0],tri[1]),(tri[1],tri[2]),(tri[2],tri[0])] {
if !edges.insert((a.min(b),a.max(b))) {continue;}
let a=vertices[a as usize];let b=vertices[b as usize];
if (a[axis]-cut)*(b[axis]-cut)<0. {
let p=a+(b-a)*((cut-a[axis])/(b[axis]-a[axis]));sides[0].push(p);sides[1].push(p);
}
}}
let parts:Option<Vec<Vec<[f32;3]>>>=sides.into_iter().map(|points| {
let shape=SharedShape::convex_hull(&points)?;
Some(shape.as_convex_polyhedron()?.to_trimesh().0.into_iter().map(|v|v.to_array()).collect())
}).collect();
let Some(mut parts)=parts else {exhausted.insert(i);continue};
let total:usize=hulls.iter().map(Vec::len).sum::<usize>()-hulls[i].len()+parts.iter().map(Vec::len).sum::<usize>();
if total>model::MAX_BODY_POINTS {break;}
hulls[i]=parts.pop().unwrap();hulls.push(parts.pop().unwrap());
}
}
impl DynamicsWorld {
/// Damage-only replica revision: retain body/collider handles and interpolation.
/// Other definition changes use the existing full replacement path.
pub fn update_replica_deformation(&mut self,id:u64,definition:&BodyDefinition) -> Result<bool,String> {
let Some(old)=self.body_definition(id) else {return Ok(false)};
let Some(field)=&definition.deformation else {return Ok(false)};
let Some(options)=definition.body.deformation.clone() else {return Err("deformation field without options".into())};
let mut compare=definition.clone();compare.body.shape=old.body.shape.clone();compare.deformation=old.deformation.clone();
if serde_json::to_vec(&compare).map_err(|e|e.to_string())? != serde_json::to_vec(&old).map_err(|e|e.to_string())? {return Ok(false);}
self.validate(&definition.body)?;
let runtime=Runtime::replica(field.clone(),options)?;
let new=self.collider(&definition.body)?;
let body=self.bodies.get(self.ids[&id]).ok_or("missing replica body")?;
if !body.is_kinematic() {return Err("only replicas accept remote deformation".into());}
let handle=*body.colliders().first().ok_or("missing replica collider")?;
let collider=self.colliders.get_mut(handle).ok_or("missing replica collider")?;
collider.set_shape(new.shared_shape().clone());
self.deformations.insert(id,runtime);
let meta=self.meta.get_mut(&id).unwrap();meta.shape=definition.body.shape.clone();
meta.definition=definition.body.clone();meta.mesh=None;meta.geometry_revision=meta.geometry_revision.wrapping_add(1);
Ok(true)
}
pub fn deformation(&self, id:u64) -> Option<&Field> {
self.deformations.get(&id).map(|r| &r.field)
}
pub fn collider_offset(&self, id:u64) -> Option<[f32;3]> {
self.meta.get(&id).map(|m|m.collider_offset)
}
pub(super) fn deform_impacts(&mut self, dt:f32) {
if self.deformations.is_empty() {return;}
let mut impacts=Vec::new();
for (&id,runtime) in &mut self.deformations {
runtime.remaining=(runtime.remaining-dt).max(0.);
if runtime.remaining>0. {continue;}
let Some(body)=self.ids.get(&id).and_then(|h|self.bodies.get(*h)) else {continue};
if !body.is_dynamic() || body.is_sleeping() {continue;}
let Some(&own)=body.colliders().first() else {continue};
let mut strongest:Option<(f32,ColliderHandle,Vector,Vector)>=None;
for pair in self.narrow_phase.contact_pairs_with(own) {
let first=pair.collider1==own;
let other=if first {pair.collider2} else {pair.collider1};
if self.colliders.get(other).is_none_or(|c|c.is_sensor()) {continue;}
let mut impulse=0.; let mut point=Vector::ZERO;let mut normal=Vector::ZERO;
for manifold in pair.solver_manifolds() {
let magnitude: f32=manifold.points.iter().map(|p|p.data.impulse).sum();
let Some(contact)=manifold.data.solver_contacts.first() else {continue};
let (a,b)=manifold.data.solver_contact_world_points(contact,&self.bodies);
impulse+=magnitude;point+=(a+b)*(0.5*magnitude);normal+=manifold.data.normal*magnitude;
}
let severity=impulse/self.meta[&id].mass;
if severity<=runtime.options.yield_speed || normal.length_squared()<1e-8
|| strongest.as_ref().is_some_and(|s|s.0>=severity) {continue;}
strongest=Some((severity,other,point/impulse,normal.normalize()*if first {-1.} else {1.}));
}
if let Some(impact)=strongest {impacts.push((id,own,impact));}
}
// Preserve the solved frame for both objects in a crash.
let impacts:Vec<_>=impacts.into_iter().filter_map(|(id,own,impact)|
Some((id,own,impact,self.colliders.get(impact.1)?.clone()))).collect();
for (id,own,(severity,_,point,inward),obstacle) in impacts {
let Some(collider)=self.colliders.get(own) else {continue};
let runtime=self.deformations.get_mut(&id).unwrap();
let mut field=runtime.field.clone();
let depth=((severity-runtime.options.yield_speed)*runtime.options.compliance).min(runtime.options.max_step);
if !field.dent(*collider.position(),point,inward,depth,&obstacle,&runtime.options) {continue;}
let Ok((hulls,shapes,shape))=runtime.rebuild(&field) else {continue};
let Some(collider)=self.colliders.get_mut(own) else {continue};
// Atomic commit. Keep authored mass/inertia and the same rigid-body
// identity, velocities and joints; geometry updates inject no impulse.
let mass=collider.mass_properties();
collider.set_shape(shape); collider.set_mass_properties(mass);
field.revision=field.revision.wrapping_add(1);
runtime.field=field;runtime.current=hulls.clone();runtime.shapes=shapes;
runtime.remaining=runtime.options.cooldown;
let meta=self.meta.get_mut(&id).unwrap();
meta.shape=Shape::Compound {hulls};meta.definition.shape=meta.shape.clone();meta.mesh=None;
meta.geometry_revision=meta.geometry_revision.wrapping_add(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn options() -> Options {Options {resolution:[9,5,9],..Default::default()}}
fn block() -> BodyDesc {BodyDesc {shape:Shape::Box {half_extents:[1.,0.5,1.]},
deformation:Some(options()),mass:1000.,linear_damping:0.,angular_damping:0.,..Default::default()}}
#[test]
fn obstacle_geometry_distinguishes_wall_from_narrow_post() {
let r=Runtime::new(&block(),options()).unwrap();
let wall=ColliderBuilder::cuboid(4.,4.,0.2).translation(Vector::new(0.,0.,1.2)).build();
let post=ColliderBuilder::cuboid(0.13,4.,0.2).translation(Vector::new(0.,0.,1.2)).build();
let mut broad=r.field.clone();let mut narrow=r.field.clone();
for (f,c) in [(&mut broad,&wall),(&mut narrow,&post)] {
assert!(f.dent(Pose::identity(),Vector::Z,-Vector::Z,0.2,c,&options()));
}
let center=[0.,0.,1.];let side=[0.75,0.,1.];
assert!(narrow.displacement(center).z < -0.15);
assert!(narrow.displacement(side).length()<0.0001);
assert!(broad.displacement(side).z < -0.02);
assert!(broad.displacement([0.,0.,-1.]).length()<0.0001);
}
#[test]
fn impacts_change_solid_shape_and_replicated_mesh_field() {
let mut w=DynamicsWorld::default();w.gravity=Vector::ZERO;
w.spawn(BodyDesc {body_type:BodyType::Static,shape:Shape::Box {half_extents:[4.,4.,0.2]},
position:[0.,0.,1.4],..Default::default()}).unwrap();
let id=w.spawn(block()).unwrap();w.set_linvel(id,[0.,0.,15.]);
let before=w.body_definition(id).unwrap();
let start=std::time::Instant::now();let mut worst=std::time::Duration::ZERO;
for _ in 0..90 {let t=std::time::Instant::now();w.step(1./120.);worst=worst.max(t.elapsed());}
eprintln!("90 collision ticks {:?}; worst tick {:?}",start.elapsed(),worst);
let after=w.body_definition(id).unwrap();
let field=after.deformation.as_ref().unwrap();
assert!(field.revision>0,"actual Rapier impact must dent the field");
assert_ne!(before.body.shape,after.body.shape);
assert!(field.offsets.iter().flatten().any(|v|v.abs()>0.001));
let handle=w.ids[&id];
assert!((w.bodies[handle].mass()-1000.).abs()<0.01);
let solid=w.solid_bodies().into_iter().find(|b|b.id==id).unwrap();
assert!(solid.colliders.iter().any(|c|c.shape.as_convex_polyhedron().is_some()));
let bytes=crate::definition_codec::encode(&after).unwrap();
let decoded=crate::definition_codec::decode(&bytes).unwrap();
let mut remote=DynamicsWorld::default();let other=remote.spawn_replica(&decoded).unwrap();
assert_eq!(remote.deformation(other).unwrap().offsets,field.offsets);
assert_eq!(remote.body_definition(other).unwrap().body.shape,after.body.shape);
remote.step(1./120.);
assert_eq!(remote.deformation(other).unwrap().revision,field.revision);
assert!((remote.solid_bodies()[0].inverse_mass-0.001).abs()<1e-7);
for cut in [0,7,bytes.len()-1] {assert!(crate::definition_codec::decode(&bytes[..cut]).is_err());}
}
#[test]
fn resting_contacts_do_not_accumulate_damage() {
let mut w=DynamicsWorld::default();
w.spawn(BodyDesc {body_type:BodyType::Static,shape:Shape::Box {half_extents:[5.,0.5,5.]},
position:[0.,-1.,0.],..Default::default()}).unwrap();
let id=w.spawn(block()).unwrap();
for _ in 0..300 {w.step(1./120.);}
assert_eq!(w.deformation(id).unwrap().revision,0);
assert!(matches!(w.body_definition(id).unwrap().body.shape,Shape::Box {..}));
}
#[test]
fn damage_is_bounded_and_invalid_input_rejected() {
let mut r=Runtime::new(&block(),options()).unwrap();
let wall=ColliderBuilder::cuboid(4.,4.,0.2).translation(Vector::new(0.,0.,1.2)).build();
for _ in 0..100 {r.field.dent(Pose::identity(),Vector::Z,-Vector::Z,0.22,&wall,&options());}
assert!(r.field.offsets.iter().all(|p|Vector::from_array(*p).length()<=0.551));
assert!(Options {resolution:[usize::MAX;3],..options()}.validate().is_err());
let mut f=r.field.clone();f.offsets[0][0]=f32::NAN;assert!(f.validate().is_err());
f=r.field;f.offsets.clear();assert!(f.validate().is_err());
}
}
#[cfg(test)]
mod fidelity_tests {
use super::*;
#[test]
fn concave_post_dent_changes_the_solid_surface_and_rotates_with_body() {
let options=Options {resolution:[9,5,9],..Default::default()};
let desc=BodyDesc {shape:Shape::Box {half_extents:[1.,0.5,1.]},..Default::default()};
let runtime=Runtime::new(&desc,options.clone()).unwrap();
let mut field=runtime.field.clone();
let obstacle=ColliderBuilder::cuboid(0.13,4.,0.2).translation(Vector::new(0.,0.,1.2)).build();
assert!(field.dent(Pose::identity(),Vector::Z,-Vector::Z,0.2,&obstacle,&options));
let (_,_,shape)=runtime.rebuild(&field).unwrap();
let hit=shape.cast_local_ray(&Ray::new(Vector::new(0.,0.,3.),-Vector::Z),5.,true).unwrap();
let visual=1.+field.displacement([0.,0.,1.]).z;
assert!((3.-hit-visual).abs()<0.015,"collider {} vs mesh {}",3.-hit,visual);
let side=shape.cast_local_ray(&Ray::new(Vector::new(0.75,0.,3.),-Vector::Z),5.,true).unwrap();
assert!((side-2.).abs()<0.001,"undamaged side must keep its collision surface");
let pose=Pose::from_parts(Vector::new(8.,3.,-4.),Rotation::from_scaled_axis(Vector::Y*1.1));
let mut rotated=runtime.field.clone();let mut obstacle=obstacle;
obstacle.set_position(pose * *obstacle.position());
assert!(rotated.dent(pose,pose*Vector::Z,pose.rotation*(-Vector::Z),0.2,&obstacle,&options));
for (a,b) in field.offsets.iter().zip(rotated.offsets) {assert!(Vector::from_array(*a).distance(Vector::from_array(b))<0.0001);}
}
#[test]
fn damage_update_preserves_remote_body_handle_and_accepts_late_join() {
let desc=BodyDesc {deformation:Some(Options::default()),..Default::default()};
let mut local=DynamicsWorld::default();let id=local.spawn(desc).unwrap();
let mut definition=local.body_definition(id).unwrap();
let mut remote=DynamicsWorld::default();let peer=remote.spawn_replica(&definition).unwrap();
let original_handle=remote.ids[&peer];
let field=definition.deformation.as_mut().unwrap();field.revision=1;
field.offsets.iter_mut().for_each(|p|p[2]=-0.1);
let runtime=&local.deformations[&id];let (hulls,_,_)=runtime.rebuild(field).unwrap();
definition.body.shape=Shape::Compound {hulls};
assert!(remote.update_replica_deformation(peer,&definition).unwrap());
assert_eq!(remote.ids[&peer],original_handle);
assert_eq!(remote.deformation(peer).unwrap().revision,1);
let mut joiner=DynamicsWorld::default();let joined=joiner.spawn_replica(&definition).unwrap();
assert_eq!(joiner.deformation(joined).unwrap().offsets,remote.deformation(peer).unwrap().offsets);
assert_eq!(remote.body_definition(peer).unwrap().body.shape,joiner.body_definition(joined).unwrap().body.shape);
}
}
+12
View File
@@ -8,6 +8,7 @@ pub use rapier3d;
pub mod solid;
pub mod visual_contact;
pub mod model;
pub mod deformation;
pub mod definition_codec;
pub use model::ModelColliderOptions;
pub use solid::{BodyDefinition, SolidBody, SolidCollider};
@@ -46,6 +47,8 @@ pub enum BodyType {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BodyDesc {
#[serde(default)]
pub deformation: Option<deformation::Options>,
pub shape: Shape,
pub body_type: BodyType,
#[serde(default = "one_mass")]
@@ -112,6 +115,7 @@ fn default_ang_damp() -> f32 {
impl Default for BodyDesc {
fn default() -> Self {
Self {
deformation: None,
shape: Shape::Box {
half_extents: [0.5, 0.5, 0.5],
},
@@ -251,6 +255,7 @@ pub const MAX_EXPORT_TRIANGLES: usize = 512;
#[derive(Clone)]
struct BodyMeta {
geometry_revision: usize,
definition: BodyDesc,
shape: Shape,
mass: f32,
@@ -341,6 +346,7 @@ enum JointKind {
}
pub struct DynamicsWorld {
deformations: BTreeMap<u64, deformation::Runtime>,
pipeline: PhysicsPipeline,
islands: IslandManager,
broad_phase: DefaultBroadPhase,
@@ -367,6 +373,7 @@ pub struct DynamicsWorld {
impl Default for DynamicsWorld {
fn default() -> Self {
Self {
deformations: BTreeMap::new(),
pipeline: PhysicsPipeline::new(),
islands: IslandManager::new(),
broad_phase: DefaultBroadPhase::default(),
@@ -441,6 +448,7 @@ impl DynamicsWorld {
pub fn spawn(&mut self, desc: BodyDesc) -> Result<u64, String> {
self.validate(&desc)?;
let deformation = desc.deformation.clone().map(|o| deformation::Runtime::new(&desc,o)).transpose()?;
let collider = self.collider(&desc)?;
// Mass comes from the collider (see collider()); do not also call
// additional_mass or density+mass stack and inflate inertia.
@@ -465,6 +473,7 @@ impl DynamicsWorld {
.insert_with_parent(collider, handle, &mut self.bodies);
let id = self.next;
self.next += 1;
if let Some(runtime)=deformation {self.deformations.insert(id,runtime);}
self.ids.insert(id, handle);
self.reverse.insert(handle, id);
if let Some(body) = self.bodies.get_mut(handle) {
@@ -473,6 +482,7 @@ impl DynamicsWorld {
self.meta.insert(
id,
BodyMeta {
geometry_revision: 0,
definition: desc.clone(),
shape: desc.shape.clone(),
mass: desc.mass.max(0.01),
@@ -493,6 +503,7 @@ impl DynamicsWorld {
};
self.reverse.remove(&handle);
self.meta.remove(&id);
self.deformations.remove(&id);
self.kinematic_motion.remove(&id);
self.bodies.remove(
handle,
@@ -1485,6 +1496,7 @@ impl DynamicsWorld {
&(),
&(),
);
self.deform_impacts(integration.dt);
}
// Rapier keeps user forces/torques until reset; clear after the frame so
// sdk.physics.force must be re-issued each fixed tick (sandbox WASD pattern).
+13 -1
View File
@@ -19,6 +19,8 @@ pub struct ExtraColliderDefinition {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BodyDefinition {
#[serde(default)]
pub deformation: Option<crate::deformation::Field>,
pub body: BodyDesc,
pub extras: Vec<ExtraColliderDefinition>,
}
@@ -140,7 +142,7 @@ impl DynamicsWorld {
self.bodies.get(*self.ids.get(&id)?).map(|b|b.local_center_of_mass().to_array())
}
pub fn definition_revision(&self, id: u64) -> Option<usize> {
self.meta.get(&id).map(|m| m.extra_hulls.len())
self.meta.get(&id).map(|m| m.geometry_revision.wrapping_mul(17).wrapping_add(m.extra_hulls.len()))
}
/// Geometry is resolved locally before export; never send or execute Lua.
@@ -150,6 +152,7 @@ impl DynamicsWorld {
body.position = [0.; 3];
body.heading = 0.;
Some(BodyDefinition {
deformation: self.deformation(id).cloned(),
body,
extras: meta.extra_hulls.iter().map(|h| ExtraColliderDefinition {
points: h.points.clone(), translation: h.translation, friction: h.friction,
@@ -161,11 +164,20 @@ impl DynamicsWorld {
if definition.extras.len() > 16 || matches!(definition.body.shape, Shape::Mesh { .. } | Shape::Model { .. }) {
return Err("unsupported or oversized replica definition".into());
}
if let Some(field)=&definition.deformation {field.validate()?;}
let runtime=match (&definition.deformation,&definition.body.deformation) {
(Some(field),Some(options))=>Some(crate::deformation::Runtime::replica(field.clone(),options.clone())?),
(None,None)=>None,
_=>return Err("replica deformation configuration/field mismatch".into()),
};
let mut desc = definition.body.clone();
desc.deformation=None;
// Ownership stays with the sender. The receiver does not resimulate
// its engine, joints, Lua callbacks or local input.
desc.body_type = BodyType::Dynamic;
let id = self.spawn(desc)?;
if let Some(runtime)=runtime {self.deformations.insert(id,runtime);}
if let Some(meta)=self.meta.get_mut(&id) {meta.definition.deformation=definition.body.deformation.clone();}
for extra in &definition.extras {
if let Err(error) = self.add_convex_collider(id, &extra.points, extra.translation, extra.friction) {
self.remove(id);
+2 -2
View File
@@ -73,7 +73,7 @@ impl Config {
Some("--start-paused") => config.start_paused = true,
Some("--teleport") => config.teleport = Some(args.next().ok_or("--teleport requires a destination ID")?.to_string_lossy().into_owned()),
Some("--difficulty") => {
let value = args.next().ok_or("--difficulty requires easy, normal or hardcore")?;
let value = args.next().ok_or("--difficulty requires easy, normal, hardcore, motorized or custom")?;
difficulty_override = Some(crate::difficulty::Difficulty::parse(&value.to_string_lossy())?);
}
Some("--verify") => {
@@ -85,7 +85,7 @@ impl Config {
}
_ => {
return Err(format!(
"Unknown argument {arg:?}. Usage: skate3rust [--assets DIRECTORY] [--map MAP.skate | --test-world] [--difficulty easy|normal|hardcore] [--verify CAPTURE.png] [--check-assets] [--start-paused]"
"Unknown argument {arg:?}. Usage: skate3rust [--assets DIRECTORY] [--map MAP.skate | --test-world] [--difficulty easy|normal|hardcore|motorized|custom] [--verify CAPTURE.png] [--check-assets] [--start-paused]"
));
}
}
+188
View File
@@ -0,0 +1,188 @@
//! User-owned difficulty values. Original collection hashes remain asset identities;
//! player-facing names describe verified consumers, not guessed original names.
use std::{collections::BTreeMap, path::Path};
use serde::{Deserialize, Serialize};
use skate_data::collections::{Collections, Field};
pub(crate) struct OptionSpec {
pub key: &'static str, pub label: &'static str, pub description: &'static str,
pub max: f32, pub step: f32, pub boolean: bool,
}
macro_rules! number {
($key:literal,$label:literal,$description:literal,$max:literal,$step:literal) => {
OptionSpec {key:$key,label:$label,description:$description,max:$max,step:$step,boolean:false}
}
}
macro_rules! toggle {
($key:literal,$label:literal,$description:literal) => {
OptionSpec {key:$key,label:$label,description:$description,max:1.,step:1.,boolean:true}
}
}
pub(crate) const OPTIONS: &[OptionSpec] = &[
toggle!("MotorEnabled","Motor assistance","Hold RB on suitable ground to drive toward the motor target speed."),
number!("MotorTopSpeed","Motor target speed (km/h)","Target speed while motor assistance is enabled and RB is held.",2000.,1.),
toggle!("AutoPushEnabled","Automatic pushing","Enable the native automatic-push behavior."),
number!("HostPushStrength","Push strength (x)","Multiplies the speed gain requested by each push animation. 1x is stock. Push limits and the normal pushing speed ceiling still apply.",100.,0.25),
number!("MaxPushDVStart","Push increase limit at low speed (m/s)","Caps the requested speed gain at low speed. Raising this alone does not make a push stronger; use Push strength.",100.,0.05),
number!("MaxPushDVEnd","Push increase limit at high speed (m/s)","Caps the requested speed gain at high speed. Raising this alone does not make a push stronger; use Push strength.",100.,0.05),
number!("PumpEffectFactor","Pumping acceleration strength","Scales speed gained by pumping transitions.",600.,0.5),
number!("PumpEffectFactorAbsorption","Pumping speed absorption","Scales speed absorbed when compressing through transitions.",300.,0.25),
number!("Hash_D77AFD320B6241C5","Pumping acceleration limit","Maximum acceleration per second allowed by the pumping controller.",400.,0.25),
number!("Hash_09D1AEE3D7D8A4A7","Pumping absorption limit","Maximum speed absorption per second allowed by the pumping controller.",400.,0.25),
number!("UnintentionalPumpScalar","Unintentional pumping strength","Scales pumping generated without the intentional-pump input.",30.,0.05),
toggle!("JumpHeightOverrideEnabled","Difficulty jump-height override","Allow difficulty jump-height settings to override animation-provided heights."),
number!("JumpMinHeight","Minimum regular jump height (m)","Lower height endpoint for regular jumps; speed curves also affect the result.",60.,0.05),
number!("Hash_BE3F74F978D777E5","Minimum jump from manual (m)","Lower jump-height endpoint when the animation marks the trick as starting from a manual.",60.,0.05),
number!("JumpMaxHeight","Maximum jump height (m)","Upper jump-height endpoint; speed curves also affect the result.",80.,0.05),
number!("GrindLockDist","Grind targeting distance (m)","Distance used by airborne grind targeting. Larger values allow more assistance.",50.,0.05),
number!("Hash_3097A69281990652","Standard grind pop - low strength","Lower vertical pop endpoint for 50-50, 5-0, backslash and darkslide exits.",100.,0.05),
number!("Hash_FA4CDBAE0DFD1FAD","Standard grind pop - high strength","Upper vertical pop endpoint for 50-50, 5-0, backslash and darkslide exits.",100.,0.05),
number!("Hash_B2B1170AFFC8AC69","Boardslide pop - low strength","Lower vertical pop endpoint when exiting a boardslide.",100.,0.05),
number!("Hash_1B3E9F9C836D287D","Boardslide pop - high strength","Upper vertical pop endpoint when exiting a boardslide.",100.,0.05),
number!("Hash_703829BD711E54DE","Tipslide pop - low strength","Lower vertical pop endpoint when exiting a nose/tail tipslide.",100.,0.05),
number!("Hash_0F2473E9125079F0","Tipslide pop - high strength","Upper vertical pop endpoint when exiting a nose/tail tipslide.",100.,0.05),
toggle!("EasyBodySpins","Assisted body spins","Enable the easier native airborne body-spin behavior."),
toggle!("PerfectBodyFlips","Assisted body flips","Enable native perfect-flip assistance."),
number!("MaxAutoBodySpinSpeed","Automatic body-spin speed limit","Maximum speed used by the automatic airborne body-spin calculation.",300.,0.25),
number!("Hash_77AFCE78FE1206CA","Speed wobble onset","Speed threshold used to activate the board wobble system.",600.,0.5),
number!("Hash_5B57F2CCCCEEF430","Speed wobble strength","Amplitude of board wobble above its activation threshold. Zero disables its amplitude.",30.,0.05),
number!("Hash_84CB2F459F3FC811","Manual correction angular-speed threshold","Angular-speed threshold used by the manual balance correction calculation.",200.,0.1),
toggle!("Hash_F8CBC0F5FEF2240E","Manual corrective force","Enable the native corrective-force branch during manuals."),
toggle!("Hash_5548109D7B0CB70C","Forgiving surface response","Remap native surface categories using Easy's surface-assistance rule."),
toggle!("WipeoutCheckForBadLanding","Bail on bad landings","Enable the bad-landing wipeout check."),
number!("Hash_D978550D6DB4E6F7","Bad-landing check scale","Scales the native bad-landing check; this is not a general damage multiplier.",50.,0.05),
number!("Wipeout_GroundXZAcceleration","Ground-impact bail threshold","Horizontal acceleration threshold used by ground wipeout checks.",400.,0.1),
toggle!("Hash_CC890A3BDCF6290A","Bail when compressed","Enable the native squash/compression wipeout check."),
toggle!("Hash_2B913C786BEFF4CD","Bail when falling upside down","Enable the airborne upside-down falling wipeout check."),
];
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Tuning { pub values: BTreeMap<String, f32> }
impl Tuning {
pub fn defaults(data: &Collections) -> Result<Self,String> {
let mut values=BTreeMap::new();
for o in OPTIONS {
let v=if o.key=="HostPushStrength" {1.} else if o.boolean { u8::from(data.boolean("physics_mode","easy",o.key)?) as f32 }
else {data.float("physics_mode","easy",o.key)?};
values.insert(o.key.into(),v);
}
Ok(Self {values})
}
pub fn load(root: &Path, data: &Collections) -> Result<Self,String> {
let mut result=Self::defaults(data)?;
let path=crate::difficulty::Difficulty::path(root).with_file_name("custom-difficulty.json");
match std::fs::read(&path) {
Ok(bytes)=> {let saved:Self=serde_json::from_slice(&bytes).map_err(|e|format!("Custom difficulty: {e}"))?;
saved.validate()?; result.values.extend(saved.values);},
Err(e) if e.kind()==std::io::ErrorKind::NotFound=>{},
Err(e)=>return Err(format!("{}: {e}",path.display())),
}
Ok(result)
}
pub fn validate(&self)->Result<(),String> {
for (key,&value) in &self.values {
let o=OPTIONS.iter().find(|o|o.key==key).ok_or_else(||format!("Unknown custom difficulty field: {key}"))?;
if !value.is_finite() || value<0. || value>o.max || (o.boolean && value!=0. && value!=1.) {
return Err(format!("Invalid value for {}",o.label));
}
}
Ok(())
}
pub fn save(&self,root:&Path)->Result<(),String> {
self.validate()?;
let path=crate::difficulty::Difficulty::path(root).with_file_name("custom-difficulty.json");
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e|e.to_string())?;
let temp=path.with_extension("json.tmp");
std::fs::write(&temp,serde_json::to_vec_pretty(self).map_err(|e|e.to_string())?).map_err(|e|e.to_string())?;
std::fs::rename(temp,path).map_err(|e|e.to_string())
}
pub fn overlay(&self,data:&mut Collections)->Result<(),String> {
self.validate()?;
let fields=self.values.iter().map(|(key,&v)| {
let o=OPTIONS.iter().find(|o|o.key==key).unwrap();
(key.clone(),Field {type_name:if o.boolean {"EA::Reflection::Bool"}else{"EA::Reflection::Float"}.into(),
data:if o.boolean {format!("{:02X}",v as u8)}else{format!("{:08X}",v.to_bits())}})
}).collect();
data.override_profile("physics_mode","test","easy",fields)
}
pub fn value(&self,index:usize)->f32 {self.values.get(OPTIONS[index].key).copied().unwrap_or(0.)}
pub fn adjust(&mut self,index:usize,direction:i32) {
let o=&OPTIONS[index];let v=self.value(index);
let v=if o.boolean {1.-v} else {(v+direction as f32*o.step).clamp(0.,o.max)};
self.values.insert(o.key.into(),v);
}
pub fn set_fraction(&mut self,index:usize,fraction:f32) {
let o=&OPTIONS[index];let v=((fraction.clamp(0.,1.)*o.max/o.step).round()*o.step).clamp(0.,o.max);
self.values.insert(o.key.into(),v);
}
pub fn label(&self,index:usize)->String {
let o=&OPTIONS[index];let v=self.value(index);
if o.boolean {format!("{} {}",o.label,if v!=0. {"On"}else{"Off"})}
else {format!("{} {:.2}",o.label,v)}
}
}
pub(crate) fn load_collections(root:&Path)->Result<Collections,String> {
let mut data=Collections::load(root)?;
Tuning::load(root,&data)?.overlay(&mut data)?;
Ok(data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_custom_values_are_rejected() {
for v in [f32::NAN,-1.,2001.] {
let mut t=Tuning::default();t.values.insert("MotorTopSpeed".into(),v);assert!(t.validate().is_err());
}
let mut t=Tuning::default();t.values.insert("MotorEnabled".into(),0.5);assert!(t.validate().is_err());
}
#[test]
fn custom_inherits_easy_without_changing_stock_or_assets() {
let mut data:Collections=serde_json::from_value(serde_json::json!({"version":1,"collections":[
{"class":"physics_mode","key":"easy","parent":"","source":"test","sha256":"","fields":{
"MotorEnabled":{"type":"EA::Reflection::Bool","data":"00"},
"MotorTopSpeed":{"type":"EA::Reflection::Float","data":"00000000"}}}
]})).unwrap();
let mut t=Tuning::default();t.values.insert("MotorTopSpeed".into(),90.);t.overlay(&mut data).unwrap();
assert_eq!(data.float("physics_mode","test","MotorTopSpeed").unwrap(),90.);
assert!(!data.boolean("physics_mode","test","MotorEnabled").unwrap());
assert_eq!(data.float("physics_mode","easy","MotorTopSpeed").unwrap(),0.);
}
}
#[cfg(test)]
mod stock_tests {
use super::*;
#[test]
#[ignore = "requires private stock collections"]
fn every_stock_difficulty_field_has_a_control_and_easy_default() {
let root=std::env::var_os("SKATE3_ASSET_ROOT").unwrap();
let mut data=Collections::load(Path::new(&root)).unwrap();
let tuning=Tuning::defaults(&data).unwrap(); tuning.validate().unwrap();
let ids:std::collections::BTreeSet<_>=OPTIONS.iter().map(|o|skate_data::attrib_hash::numeric_name(o.key)).collect();
assert_eq!(ids.len(),OPTIONS.len());
for c in data.entries().iter().filter(|c|c.class_name=="physics_mode") {
for key in c.fields.keys() {assert!(ids.contains(&skate_data::attrib_hash::numeric_name(key)),"Missing control for {key}");}
}
tuning.overlay(&mut data).unwrap();
for o in OPTIONS.iter().filter(|o|o.key!="HostPushStrength") { assert_eq!(data.field("physics_mode","easy",o.key).unwrap().data,
data.field("physics_mode","test",o.key).unwrap().data, "{}",o.label); }
}
}
#[cfg(test)]
mod push_slider_tests {
use super::*;
#[test]
fn strength_adjustment_and_saved_values_round_trip() {
let i=OPTIONS.iter().position(|o|o.key=="HostPushStrength").unwrap();
let mut t=Tuning::default();t.values.insert("HostPushStrength".into(),1.);
t.adjust(i,1);assert_eq!(t.value(i),1.25);
t.set_fraction(i,0.5);assert_eq!(t.value(i),50.);
let saved=serde_json::to_vec(&t).unwrap();
let loaded:Tuning=serde_json::from_slice(&saved).unwrap();assert_eq!(loaded.value(i),50.);
}
}
+9 -5
View File
@@ -12,16 +12,20 @@ pub(crate) enum Difficulty {
Easy = 0,
Normal = 1,
Hardcore = 2,
Motorized = 3,
Custom = 4,
}
impl Difficulty {
pub const ALL: [Self; 3] = [Self::Easy, Self::Normal, Self::Hardcore];
pub fn key(self) -> &'static str { NATIVE_MODES[self as usize] }
pub const ALL: [Self; 5] = [Self::Easy, Self::Normal, Self::Hardcore, Self::Motorized, Self::Custom];
pub fn key(self) -> &'static str { if self == Self::Custom { "custom" } else { self.profile_key() } }
// Custom owns the unused native test slot; stock modes retain their indices.
pub fn profile_key(self) -> &'static str { NATIVE_MODES[self as usize] }
pub fn label(self) -> &'static str {
match self { Self::Easy => "Easy", Self::Normal => "Normal", Self::Hardcore => "Hardcore" }
match self { Self::Easy => "Easy", Self::Normal => "Normal", Self::Hardcore => "Hardcore", Self::Motorized => "Motorized", Self::Custom => "Custom" }
}
pub fn parse(value: &str) -> Result<Self, String> {
Self::ALL.into_iter().find(|d| d.key().eq_ignore_ascii_case(value))
.ok_or_else(|| format!("Unknown difficulty {value:?}; expected easy, normal or hardcore"))
.ok_or_else(|| format!("Unknown difficulty {value:?}; expected easy, normal, hardcore, motorized or custom"))
}
pub fn path(root: &Path) -> PathBuf {
root.parent().unwrap_or(root).join("settings/gameplay.json")
@@ -55,7 +59,7 @@ mod tests {
let bytes = serde_json::to_vec(&Saved { difficulty: d }).unwrap();
assert_eq!(serde_json::from_slice::<Saved>(&bytes).unwrap().difficulty, d);
}
assert!(Difficulty::parse("motorized").is_err());
assert!(Difficulty::parse("test").is_err());
assert!(serde_json::from_str::<Saved>(r#"{"difficulty":"made-up"}"#).is_err());
}
}
+71 -8
View File
@@ -1,5 +1,7 @@
//! Native-resolution pause UI over a separately scaled 3D render target.
use crate::difficulty::Difficulty;
use crate::custom_difficulty::{Tuning, OPTIONS};
use bevy::ui::RelativeCursorPosition;
use bevy::{
camera::RenderTarget,
image::ImageSampler,
@@ -80,6 +82,10 @@ pub(crate) struct Menu {
settings: GraphicsSettings,
path: PathBuf,
difficulty: Difficulty,
custom: Tuning,
custom_defaults: Tuning,
custom_apply: bool,
custom_dirty: bool,
status: String,
maps: Vec<crate::map_library::Entry>,
selected_map: usize,
@@ -145,6 +151,7 @@ impl Menu {
rows
}
0 => (1000..1000 + self.maps.len()).collect(),
1 if self.difficulty == Difficulty::Custom => std::iter::once(3).chain(300..337).chain([8,10]).collect(),
1 => vec![3, 8, 10],
2 => vec![0, 1, 2, 13],
4 => vec![7, 11, 14],
@@ -191,10 +198,11 @@ impl Plugin for GraphicsMenuPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(FramePacer(Instant::now()))
.add_systems(PostStartup, setup.in_set(PresentationSetup))
.add_systems(PreUpdate, (refresh_sections, interact).chain().in_set(MenuInput).after(bevy::input::InputSystems))
.add_systems(PreUpdate, (refresh_sections, interact, apply_custom_difficulty).chain().in_set(MenuInput).after(bevy::input::InputSystems))
.add_systems(PreUpdate, finish_menu_travel.after(crate::map_transition::MapTransitionSet).before(crate::input::poll_controllers))
.add_systems(PreUpdate, toggle_fullscreen.after(bevy::input::InputSystems))
.add_systems(Update, preview_menu.before(labels))
.add_systems(Update, custom_sliders.before(labels))
.add_systems(Update, (crate::map_render::advance_day, apply, labels, scroll_menu, resize_menu).chain())
.add_systems(PostUpdate, crate::map_render::position_celestial_bodies.before(bevy::transform::TransformSystems::Propagate))
.add_systems(Last, pace);
@@ -254,6 +262,9 @@ fn setup(
ImageNode::new(target.clone()),
UiTargetCamera(output),
));
let data = skate_data::collections::Collections::load(&config.asset_root).expect("validated startup collections");
let custom_defaults = Tuning::defaults(&data).expect("validated Easy difficulty");
let custom = Tuning::load(&config.asset_root, &data).expect("validated Custom difficulty");
let maps = crate::map_library::discover(&config.asset_root);
let destinations = crate::teleport_menu::load(&config.asset_root).unwrap_or_else(|e| { warn!("Map destinations: {e}"); Vec::new() });
commands.spawn((MenuRoot, MenuLayoutRoot, UiTargetCamera(output), GlobalZIndex(10), Node {
@@ -277,13 +288,19 @@ fn setup(
body.spawn((Text::new(""),MenuSubtitle,TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.65,0.72,0.72))));
body.spawn((Node {height:px(3),width:px(64),margin:UiRect::bottom(px(10)),..default()},BackgroundColor(Color::srgb(0.78,0.96,0.3))));
body.spawn((MenuScroll,ScrollPosition::default(),Node {flex_grow:1.,min_height:px(0),overflow:Overflow::scroll_y(),flex_direction:FlexDirection::Column,row_gap:px(8),..default()})).with_children(|list| {
for i in (0..10).chain(11..16).chain(20..27).chain([10]).chain(200..264).chain([50,51]).chain(1000..1000+maps.len()).chain(1_000_000..1_000_000+destinations.len()) {
list.spawn((Button,MenuRow(i),Node {width:percent(100),min_height:px(56),flex_shrink:0.,padding:UiRect::axes(px(18),px(12)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(4)),..default()},BackgroundColor(Color::srgb(0.075,0.09,0.095))))
.with_child((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
for i in (0..4).chain(300..337).chain(4..10).chain(11..16).chain(20..27).chain([10]).chain(200..264).chain([50,51]).chain(1000..1000+maps.len()).chain(1_000_000..1_000_000+destinations.len()) {
list.spawn((Button,MenuRow(i),Node {flex_direction:if (300..335).contains(&i) {FlexDirection::Column} else {FlexDirection::Row},width:percent(100),min_height:px(56),flex_shrink:0.,padding:UiRect::axes(px(18),px(12)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(4)),..default()},BackgroundColor(Color::srgb(0.075,0.09,0.095))))
.with_children(|row| {
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
if (300..335).contains(&i) && !OPTIONS[i-300].boolean {
row.spawn((CustomTrack(i-300),RelativeCursorPosition::default(),Node {width:percent(100),height:px(14),margin:UiRect::top(px(8)),..default()},BackgroundColor(Color::srgb(0.15,0.19,0.20))))
.with_child((CustomFill(i-300),Node {width:percent(0),height:percent(100),..default()},BackgroundColor(Color::srgb(0.65,0.85,0.25))));
}
});
}
});
body.spawn((StatusLabel,Text::new(""),TextFont {font_size:14.,..default()},TextColor(Color::srgb(0.78,0.96,0.3))));
body.spawn((Text::new("Up/Down Navigate Enter / A Select Left/Right Adjust Tab Sections\nSettings save automatically"),TextFont {font_size:13.,..default()},TextColor(Color::srgb(0.55,0.62,0.62))));
body.spawn((Text::new("Up/Down Navigate Enter / A Select Left/Right Adjust Tab Sections\nCustom tuning: use Apply below. Other settings save automatically."),TextFont {font_size:13.,..default()},TextColor(Color::srgb(0.55,0.62,0.62))));
});
});
});
@@ -296,6 +313,7 @@ fn setup(
settings,
path,
difficulty: config.difficulty,
custom, custom_defaults, custom_apply:false, custom_dirty:false,
status: String::new(),
maps,
selected_map,
@@ -405,7 +423,7 @@ pub(crate) fn interact(
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
menu.selected = visible[(index + 1) % rows];
}
let adjustable = (menu.daylight && menu.selected < 3) || (!menu.multiplayer && !menu.daylight && menu.selected < 4);
let adjustable = (menu.section == 1 && menu.difficulty == Difficulty::Custom && (300..335).contains(&menu.selected)) || (menu.daylight && menu.selected < 3) || (!menu.multiplayer && !menu.daylight && menu.selected < 4);
if adjustable && (keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0) {
action = Some((menu.selected, -1));
}
@@ -528,6 +546,9 @@ pub(crate) fn interact(
}
} else {
match row {
300..=334 => { menu.custom.adjust(row-300,direction); menu.custom_dirty=true; },
335 => menu.custom_apply=true,
336 => { menu.custom=menu.custom_defaults.clone(); menu.custom_dirty=true; menu.status="Easy values restored. Select Apply custom tuning to use them.".into(); },
0 => {
let size = cycle(
RESOLUTIONS,
@@ -782,6 +803,9 @@ fn labels(
}
),
3 => format!("Difficulty {}", menu.difficulty.label()),
300..=334 => menu.custom.label(label.0-300),
335 => if menu.custom_dirty {"Apply custom tuning *".into()} else {"Apply custom tuning".into()},
336 => "Reset custom tuning to Easy".into(),
6 => "Resume".into(),
7 => "Quit game".into(),
8 => "Character customiser".into(),
@@ -810,6 +834,9 @@ fn labels(
format!("\nYour connection code: {}", net.host_code)
}
)
} else if menu.section == 1 && menu.difficulty == Difficulty::Custom && (300..335).contains(&menu.selected) {
format!("{}\n{}", OPTIONS[menu.selected-300].description,
if menu.custom_dirty {"Changes pending - select Apply custom tuning below. Left/Right adjusts; drag bars with the mouse."} else {"Left/Right adjusts; drag bars with the mouse. Custom starts from Easy."})
} else {
menu.status.clone()
};
@@ -828,6 +855,7 @@ fn preview_menu(config: Res<crate::config::Config>, mut menu: ResMut<Menu>, mut
if *done || config.verification_capture.is_none() { return; }
*done = true;
match std::env::var("SKATE_VERIFY_MENU").as_deref() {
Ok("custom") => { menu.open = true; menu.select_section(1); menu.selected=300; },
Ok("mods") => { menu.open = true; mods.begin(); }
Ok("multiplayer") => { menu.open = true; menu.select_section(3); }
_ => {}
@@ -934,7 +962,7 @@ mod tests {
.insert_resource(images)
.insert_resource(Menu {
open: false, selected: 0, settings: GraphicsSettings::default(),
difficulty: Difficulty::Easy, path: PathBuf::new(), status: String::new(),
difficulty: Difficulty::Easy, custom:Tuning::default(), custom_defaults:Tuning::default(), custom_apply:false, custom_dirty:false, path: PathBuf::new(), status: String::new(),
multiplayer: false, browser: false, network_page: 0, browser_count: 0, daylight: false, section: 0, custom_sections: Vec::new(), map_detail: false, destinations: Vec::new(), pending_travel: None,
maps: vec![crate::map_library::Entry { label: "Test world".into(), path: None }], selected_map: 0,
})
@@ -974,7 +1002,7 @@ mod tests {
fn sections_expose_only_real_rows_and_all_maps() {
let mut menu = Menu {
open: true, selected: 1000, settings: GraphicsSettings::default(),
path: PathBuf::new(), difficulty: Difficulty::Easy, status: String::new(),
path: PathBuf::new(), difficulty: Difficulty::Easy, custom:Tuning::default(), custom_defaults:Tuning::default(), custom_apply:false, custom_dirty:false, status: String::new(),
maps: (0..40).map(|i| crate::map_library::Entry { label: format!("Map {i}"), path: None }).collect(),
selected_map: 0, multiplayer: false, browser: false, network_page: 0, browser_count: 0, daylight: false, section: 0, custom_sections: Vec::new(), map_detail: false, destinations: Vec::new(), pending_travel: None,
};
@@ -1044,3 +1072,38 @@ mod tests {
assert_eq!(cycle(LIMITS, 240, 1), 0);
}
}
#[derive(Component)] struct CustomTrack(usize);
#[derive(Component)] struct CustomFill(usize);
fn custom_sliders(mut menu: ResMut<Menu>, mouse: Res<ButtonInput<MouseButton>>,
tracks: Query<(&CustomTrack,&RelativeCursorPosition)>, mut fills: Query<(&CustomFill,&mut Node)>,
mut dragging: Local<Option<usize>>) {
if !menu.open || menu.section != 1 || menu.difficulty != Difficulty::Custom { *dragging=None; return; }
if mouse.just_pressed(MouseButton::Left) {
*dragging=tracks.iter().find(|(_,p)|p.cursor_over()).map(|(t,_)|t.0);
}
if !mouse.pressed(MouseButton::Left) { *dragging=None; }
if let Some(index)=*dragging {
if let Some((_,position))=tracks.iter().find(|(t,_)|t.0==index) {
if let Some(pos)=position.normalized {
menu.custom.set_fraction(index,pos.x); menu.custom_dirty=true; menu.selected=300+index;
}
}
}
for (fill,mut node) in &mut fills {node.width=percent(menu.custom.value(fill.0)/OPTIONS[fill.0].max*100.);}
}
fn apply_custom_difficulty(mut menu: ResMut<Menu>, config: Res<crate::config::Config>,
mut skater: ResMut<crate::physics::SkaterRuntime>) {
if !std::mem::take(&mut menu.custom_apply) {return;}
let result=(|| -> Result<(),String> {
let mut data=skate_data::collections::Collections::load(&config.asset_root)?;
menu.custom.overlay(&mut data)?;
skater.reload_difficulty(&data)?;
menu.custom.save(&config.asset_root)?;
Ok(())
})();
menu.status=match result {
Ok(())=> {menu.custom_dirty=false;"Custom tuning applied and saved.".into()},
Err(e)=>format!("Custom tuning: {e}"),
};
}
+2 -1
View File
@@ -22,6 +22,7 @@ mod map_library;
mod map_render;
mod map_transition;
mod difficulty;
mod custom_difficulty;
mod graph_host;
mod graph_runtime;
mod input;
@@ -116,7 +117,7 @@ fn main() -> bevy::app::AppExit {
physics.board.set_transform(spawn);
}
eprintln!("REPORT_META stage=skater_initialization");
let skater = match bevy::log::info_span!("load_skater").in_scope(|| physics::SkaterRuntime::load(&config.asset_root, &graphs, &physics, config.difficulty.key())) {
let skater = match bevy::log::info_span!("load_skater").in_scope(|| physics::SkaterRuntime::load(&config.asset_root, &graphs, &physics, config.difficulty.profile_key())) {
Ok(skater) => skater,
Err(error) => {
eprintln!("{error}");
+1 -1
View File
@@ -131,7 +131,7 @@ fn start(world: &World, entry: Entry) -> Result<Phase, String> {
stage.store(1, Ordering::Relaxed);
let physics = info_span!("load_physics").in_scope(|| GamePhysics::load_with_difficulty(&root, map.as_ref(), difficulty))?;
stage.store(2, Ordering::Relaxed);
let skater = SkaterRuntime::load_for_world(&root, &graphs, &physics, difficulty.key(), Some(source))?;
let skater = SkaterRuntime::load_for_world(&root, &graphs, &physics, difficulty.profile_key(), Some(source))?;
let mut controls = PlayerControls::load(&root)?;
controls.preferences = preferences;
let camera = crate::camera::CameraRuntime::load(&root)?;
+81 -1
View File
@@ -10,6 +10,7 @@ pub(super) struct TimedNode { pub state:NodeState, pub received:Instant }
#[derive(Clone,Copy)]
struct Binding { entity:Entity, authored:Transform }
pub(super) struct Owned {
pub(super) deformation: super::graphics_deformation::State,
pub entity:Entity,
pub mesh:Option<AssetId<Mesh>>,
pub material:Option<AssetId<StandardMaterial>>,
@@ -22,7 +23,7 @@ pub(super) struct Owned {
bindings:BTreeMap<String,Binding>,
ambiguous:BTreeSet<String>,
warned:BTreeSet<String>,
ready:bool,
pub(super) ready:bool,
}
pub(super) fn transform(state:&TransformState) -> Transform {
@@ -90,6 +91,7 @@ pub(super) fn spawn(
mods.graphics_serial=mods.graphics_serial.wrapping_add(1);
world.entity_mut(entity).insert((crate::retail_character::ModGraphicsLit,bevy::camera::visibility::RenderLayers::from_layers(&[0,28])));
mods.graphics.insert(slot,Owned {
deformation: Default::default(),
entity,mesh,material,body:definition.body.clone(),definition,transform:state,visible,
serial:serial.unwrap_or(mods.graphics_serial),nodes:BTreeMap::new(),bindings:BTreeMap::new(),
ambiguous:BTreeSet::new(),warned:BTreeSet::new(),ready:false,
@@ -171,6 +173,11 @@ pub(super) fn sync(world:&mut World,mods:&mut Mods) {
if let Some(mut current)=world.get_mut::<Transform>(owned.entity) { if *current!=t {*current=t;} }
if let Some(mut current)=world.get_mut::<Visibility>(owned.entity) { let next=if visible { Visibility::Visible } else { Visibility::Hidden }; if *current!=next {*current=next;} }
bind(world,owned);
if let Some(id)=owned.body.as_ref().and_then(|body|mods.bodies.get(&(owner.clone(),body.clone()))).copied() {
if let Some(field)=mods.world.deformation(id) {
super::graphics_deformation::sync(world,owned,id,field,mods.world.collider_offset(id).unwrap_or([0.;3]));
}
}
for (name,node) in &owned.nodes {
if owned.ambiguous.contains(name) || !owned.bindings.contains_key(name) {
if owned.ready && owned.warned.insert(name.clone()) {
@@ -266,3 +273,76 @@ mod tests {
assert_eq!(node_transform(Transform::IDENTITY,&state,1.),node_transform(Transform::IDENTITY,&state,0.1));
}
}
#[cfg(test)]
mod deformation_tests {
use super::*;
use bevy::mesh::VertexAttributeValues;
#[test]
fn deformation_is_instance_local_and_keeps_glb_materials_and_rigid_parts() {
let mut world=World::new();world.init_resource::<Assets<Mesh>>();
world.init_resource::<Assets<StandardMaterial>>();
let source=world.resource_mut::<Assets<Mesh>>().add(Cuboid::new(1.,1.,1.));
let material=world.resource_mut::<Assets<StandardMaterial>>().add(StandardMaterial {metallic:0.9,..default()});
let root=world.spawn(Transform::IDENTITY).id();
let panel=world.spawn((Name::new("panel"),Transform::from_xyz(0.5,0.,0.),ChildOf(root),
Mesh3d(source.clone()),MeshMaterial3d(material.clone()))).id();
let rigid=world.spawn((Name::new("rigid"),Transform::IDENTITY,ChildOf(root),Mesh3d(source.clone()))).id();
let mut owned=Owned {entity:root,mesh:None,material:None,body:Some("object".into()),
definition:GraphicsDefinition {path:String::new(),body:Some("object".into()),color:[1.;3],opacity:1.,deform_nodes:vec!["panel".into()]},
transform:TransformState {position:[0.25,0.,0.],scale:[2.,1.,1.],..default()},visible:true,serial:1,
nodes:BTreeMap::new(),bindings:BTreeMap::new(),ambiguous:BTreeSet::new(),warned:BTreeSet::new(),ready:true,
deformation:default()};
let field=skate_dynamics::deformation::Field {min:[-4.;3],max:[4.;3],resolution:[2;3],offsets:vec![[0.,0.,-0.2];8],revision:1};
super::super::graphics_deformation::sync(&mut world,&mut owned,1,&field,[0.25,0.,0.]);
let handle=world.get::<Mesh3d>(panel).unwrap().0.clone();
assert_ne!(handle,source);
assert_eq!(world.get::<Mesh3d>(rigid).unwrap().0,source);
assert_eq!(world.get::<MeshMaterial3d<StandardMaterial>>(panel).unwrap().0,material);
let meshes=world.resource::<Assets<Mesh>>();
let Some(VertexAttributeValues::Float32x3(original))=meshes.get(&source).unwrap().attribute(Mesh::ATTRIBUTE_POSITION) else {panic!()};
let Some(VertexAttributeValues::Float32x3(damaged))=meshes.get(&handle).unwrap().attribute(Mesh::ATTRIBUTE_POSITION) else {panic!()};
for (a,b) in original.iter().zip(damaged) {assert!((b[2]-a[2]+0.2).abs()<1e-5);assert_eq!(b[0],a[0]);}
super::super::graphics_deformation::sync(&mut world,&mut owned,1,&field,[0.25,0.,0.]);
assert_eq!(world.get::<Mesh3d>(panel).unwrap().0,handle);
// New replica layout rebinds weights to retained ORIGINAL vertices.
let repaired=skate_dynamics::deformation::Field {min:[-4.;3],max:[4.;3],resolution:[3;3],offsets:vec![[0.;3];27],revision:0};
super::super::graphics_deformation::sync(&mut world,&mut owned,2,&repaired,[0.25,0.,0.]);
let meshes=world.resource::<Assets<Mesh>>();
assert_eq!(meshes.get(&handle).unwrap().attribute(Mesh::ATTRIBUTE_POSITION),meshes.get(&source).unwrap().attribute(Mesh::ATTRIBUTE_POSITION));
owned.deformation.clear(&mut world);
assert!(world.resource::<Assets<Mesh>>().get(&handle).is_none());
assert!(world.resource::<Assets<Mesh>>().get(&source).is_some());
}
}
#[cfg(test)]
mod deformation_timing {
use super::*;
#[test]
#[ignore = "manual actual Skyline asset timing probe"]
fn actual_asset_deformation_cpu_cost() {
let path=std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../mods/Skyline_Drive_Mod/skyline.glb");
let geometry=skate_mods::model::read_geometry_file(&path,"skyline_mesh",&Default::default()).unwrap();
let vertices=geometry.vertices.len();
let mut source=Mesh::new(bevy::mesh::PrimitiveTopology::TriangleList,bevy::asset::RenderAssetUsages::default());
source.insert_attribute(Mesh::ATTRIBUTE_POSITION,geometry.vertices);
source.insert_indices(bevy::mesh::Indices::U32(geometry.triangles.into_iter().flatten().collect()));
source.compute_normals();
let mut world=World::new();world.init_resource::<Assets<Mesh>>();
let source=world.resource_mut::<Assets<Mesh>>().add(source);
let root=world.spawn(Transform::IDENTITY).id();
world.spawn((Name::new("shell"),Transform::IDENTITY,ChildOf(root),Mesh3d(source)));
let mut owned=Owned {entity:root,mesh:None,material:None,body:Some("object".into()),
definition:GraphicsDefinition {path:String::new(),body:Some("object".into()),color:[1.;3],opacity:1.,deform_nodes:vec!["shell".into()]},
transform:default(),visible:true,serial:1,nodes:BTreeMap::new(),bindings:BTreeMap::new(),ambiguous:BTreeSet::new(),warned:BTreeSet::new(),ready:true,deformation:default()};
let mut field=skate_dynamics::deformation::Field {min:[-2.,-1.,-3.],max:[2.,2.,3.],resolution:[9,5,17],offsets:vec![[0.;3];9*5*17],revision:1};
for i in 0..field.offsets.len() {let p=field.rest(i);if p.z>1. {field.offsets[i][2]=-0.2;}}
let t=std::time::Instant::now();super::super::graphics_deformation::sync(&mut world,&mut owned,1,&field,[0.;3]);
let initial=t.elapsed();
field.revision+=1;for p in &mut field.offsets {p[2]*=1.2;}
let t=std::time::Instant::now();super::super::graphics_deformation::sync(&mut world,&mut owned,1,&field,[0.;3]);let update=t.elapsed();
let t=std::time::Instant::now();for _ in 0..10000 {super::super::graphics_deformation::sync(&mut world,&mut owned,1,&field,[0.;3]);}
eprintln!("Skyline welded vertices={vertices}; initial mesh bind/update={initial:?}; subsequent damage={update:?}; 10000 unchanged checks={:?}; excludes GPU upload/tangents",t.elapsed());
}
}
@@ -0,0 +1,103 @@
//! Instance-local GLB deformation. Authored materials/UVs remain intact.
use bevy::{prelude::*, mesh::{VertexAttributeValues, PrimitiveTopology}};
use skate_dynamics::deformation::Field;
#[derive(Default)]
pub(super) struct State {
revision: Option<(u64,u64)>,
parts: Vec<Part>,
}
struct Part {
entity: Entity,
mesh: Handle<Mesh>,
rest: Vec<[f32;3]>,
previous: Vec<[f32;3]>,
private: bool,
weights: Vec<([usize;8],[f32;8])>,
to_mesh: Mat4,
}
impl State {
pub(super) fn clear(self, world:&mut World) {
let mut meshes=world.resource_mut::<Assets<Mesh>>();
for part in self.parts {if part.private {meshes.remove(part.mesh.id());}}
}
}
fn binding(world:&World, root:Entity, entity:Entity, names:&[String]) -> Option<Mat4> {
let mut current=entity;
let mut matrix=Mat4::IDENTITY;
let mut selected=false;
// Only traverse this scene; a GLB name can never bind another mod's mesh.
for _ in 0..128 {
if current==root {return selected.then_some(matrix);}
selected |= world.get::<Name>(current).is_some_and(|n|names.iter().any(|s|s==n.as_str()));
matrix=world.get::<Transform>(current)?.to_matrix()*matrix;
current=world.get::<ChildOf>(current)?.parent();
}
None
}
pub(super) fn sync(world:&mut World, owned:&mut super::graphics::Owned,
id:u64, field:&Field, offset:[f32;3]) {
if owned.definition.deform_nodes.is_empty() || !owned.ready
|| owned.deformation.revision==Some((id,field.revision)) {return;}
// Don't clone/upload pristine meshes. Only a real damage revision allocates.
if field.revision==0 && owned.deformation.parts.is_empty() {
owned.deformation.revision=Some((id,0)); return;
}
if owned.deformation.parts.is_empty() {
let mut entities=Vec::new();let mut pending=vec![owned.entity];
while let Some(e)=pending.pop() {
if let Some(children)=world.get::<Children>(e) {pending.extend(children.iter());}
if world.get::<Mesh3d>(e).is_some() {entities.push(e);}
}
let root=super::graphics::transform(&owned.transform).to_matrix();
let mut total=0usize;
for entity in entities {
let Some(local)=binding(world,owned.entity,entity,&owned.definition.deform_nodes) else {continue};
let to_body=Mat4::from_translation(-Vec3::from_array(offset))*root*local;
if !to_body.is_finite() || to_body.determinant().abs()<1e-8 {continue;}
let Some(handle)=world.get::<Mesh3d>(entity).map(|m|m.0.clone()) else {continue};
let Some(mesh)=world.resource::<Assets<Mesh>>().get(&handle) else {continue};
if mesh.primitive_topology()!=PrimitiveTopology::TriangleList {continue;}
let Some(VertexAttributeValues::Float32x3(positions))=mesh.attribute(Mesh::ATTRIBUTE_POSITION) else {continue};
total+=positions.len();
if total>262_144 {warn!("deformation instance exceeds 262144 vertices");break;}
let rest=positions.clone();
let weights=rest.iter().map(|p|field.weights(to_body.transform_point3(Vec3::from_array(*p)).to_array())).collect();
owned.deformation.parts.push(Part {entity,mesh:handle,previous:rest.clone(),private:false,rest,weights,to_mesh:to_body.inverse()});
}
if owned.deformation.parts.is_empty() {
warn!("deformation binding matched no readable GLB triangle meshes");
}
}
if owned.deformation.revision.is_some_and(|(previous,_)|previous!=id) {
for part in &mut owned.deformation.parts {
let to_body=part.to_mesh.inverse();
part.weights=part.rest.iter().map(|p|field.weights(to_body.transform_point3(Vec3::from_array(*p)).to_array())).collect();
}
}
for part in &mut owned.deformation.parts {
let positions:Vec<_>=part.rest.iter().zip(&part.weights).map(|(rest,(ids,weights))| {
let displacement=(0..8).fold(Vec3::ZERO,|v,i|v+Vec3::from_array(field.offsets[ids[i]])*weights[i]);
(Vec3::from_array(*rest)+part.to_mesh.transform_vector3(displacement)).to_array()
}).collect();
if positions.iter().zip(&part.previous).all(|(a,b)|Vec3::from_array(*a).distance_squared(Vec3::from_array(*b))<1e-10) {continue;}
if !part.private {
let Some(clone)=world.resource::<Assets<Mesh>>().get(&part.mesh).cloned() else {continue};
part.mesh=world.resource_mut::<Assets<Mesh>>().add(clone);part.private=true;
world.entity_mut(part.entity).insert(Mesh3d(part.mesh.clone()));
}
part.previous=positions.clone();
let mut meshes=world.resource_mut::<Assets<Mesh>>();
let Some(mesh)=meshes.get_mut(&part.mesh) else {continue};
let tangents=mesh.contains_attribute(Mesh::ATTRIBUTE_TANGENT);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION,positions);
mesh.compute_normals();
if tangents {let _=mesh.generate_tangents();}
drop(meshes);
// Bevy recalculates the culling bounds; old bounds must not clip dents.
world.entity_mut(part.entity).remove::<bevy::camera::primitives::Aabb>();
}
owned.deformation.revision=Some((id,field.revision));
}
+4 -2
View File
@@ -6,6 +6,7 @@ mod vehicle_camera;
pub(crate) mod bridge;
pub(crate) mod replication;
mod graphics;
mod graphics_deformation;
pub(crate) mod attachment;
mod glb;
mod menu;
@@ -775,6 +776,7 @@ fn clear_runtime(world: &mut World, mods: &mut Mods) {
fn retire_graphics(world: &mut World, mods: &mut Mods, key: &(String, String)) {
if let Some(owned) = mods.graphics.remove(key) {
owned.deformation.clear(world);
world.despawn(owned.entity);
if let Some(id) = owned.mesh {
world.resource_mut::<Assets<Mesh>>().remove(id);
@@ -1181,9 +1183,9 @@ fn apply_one(
mods.world.remove_joint(j);
}
}
Command::GraphicsMesh { key,path,body,position,rotation,scale,color,visible,opacity } => {
Command::GraphicsMesh { key,path,body,position,rotation,scale,color,visible,opacity,deform_nodes } => {
graphics::spawn(world,mods,id,id,key,
skate_mods::scene::GraphicsDefinition { path,body,color,opacity },
skate_mods::scene::GraphicsDefinition { path,body,color,opacity,deform_nodes },
skate_mods::scene::TransformState { position:position.unwrap_or([0.;3]),rotation:rotation.unwrap_or([0.,0.,0.,1.]),scale },visible,None)?;
}
Command::GraphicsTransform { key, options } => {
+13 -1
View File
@@ -236,15 +236,27 @@ fn incoming(world:&mut World,mods:&mut Mods,state:&mut State,records:Vec<(u64,St
state.pending_poses.insert((owner(peer,&packet.owner),packet.key.clone()),(pose.clone(),Instant::now()));
let changed=state.bodies.get(&slot).is_none_or(|r|r.epoch!=packet.epoch || r.instance!=packet.instance || r.revision!=packet.revision);
if changed {
remove_body(mods,state,&slot);
// Geometry may span multiple packets. Continue moving the previous
// valid solid while assembling, rather than freezing/removing it.
if let Some(replica)=state.bodies.get_mut(&slot).filter(|r|r.epoch==packet.epoch && r.instance==packet.instance) {
replica.receive(pose.clone(),seq);
}
let Some(bytes)=assemble(packet,accepted.iter().filter(|r|r.0==peer).map(|r|&r.2)) else {continue;};
let definition=match skate_dynamics::definition_codec::decode(&bytes) {
Ok(definition)=>definition,
Err(error)=>{issue(state,format!("{}: remote collider definition: {error}",packet.owner));continue;}
};
if !(0.01..=10_000_000.).contains(&definition.body.mass) {continue;}
if let Some(replica)=state.bodies.get_mut(&slot).filter(|r|r.epoch==packet.epoch && r.instance==packet.instance) {
match mods.world.update_replica_deformation(replica.id,&definition) {
Ok(true)=>{replica.revision=packet.revision;continue;},
Ok(false)=>{},
Err(error)=>{issue(state,format!("remote deformation: {error}"));continue;},
}
}
match mods.world.spawn_replica(&definition) {
Ok(id) => {
remove_body(mods,state,&slot);
mods.world.set_pose(id,pose.p,pose.q);
mods.world.set_kinematic_motion(id,pose.v,pose.w);
mods.bodies.insert((owner(peer,&packet.owner),packet.key.clone()),id);
+7 -3
View File
@@ -92,7 +92,7 @@ use skate_core::{
drive_frames::RetailAffineTransform,
},
};
use skate_data::collections::Collections;
#[derive(Resource)]
pub(crate) struct GamePhysics {
@@ -266,9 +266,9 @@ impl GamePhysics {
}
fn load_world_difficulty(asset_root: &std::path::Path, terrain: ground::Terrain, map: Option<&skate_data::skate_map::SkateMap>, difficulty: crate::difficulty::Difficulty) -> Result<Self, String> {
let data = Collections::load(asset_root)?;
let data = crate::custom_difficulty::load_collections(asset_root)?;
let settings = PhysicsSettings::load(&data)?;
let animation_profile = animation_phase::AnimationProfile::load(&data, difficulty.key())?;
let animation_profile = animation_phase::AnimationProfile::load(&data, difficulty.profile_key())?;
eprintln!(
"SKATE_PHYSICS_MODE {} index={}",
difficulty.key(), animation_profile.physics_mode
@@ -613,3 +613,7 @@ mod offboard_root_trace;
#[cfg(test)]
#[path = "tests/gameplay_gestures.rs"]
mod gameplay_gesture_tests;
#[cfg(test)]
#[path = "tests/difficulty.rs"]
mod difficulty_tests;
@@ -16,6 +16,7 @@ pub(crate) struct AirReckoning {
modes: [settings::Mode; 5],
}
impl AirReckoning {
pub(crate) fn adopt_mode_settings(&mut self, other: Self) { self.modes = other.modes; }
pub fn update_plant(&mut self,riding:&mut RidingOutputs,p:&ProcessedPhysicsInput,up:[f32;4],heading:[f32;4]) {
reckoning::update_plant(&mut riding.reckoning,&mut riding.reckoning_frames,
&mut riding.body_spin,&mut self.state,&self.settings,up,heading,p.flags_2468&(1<<20)!=0);
@@ -33,6 +33,7 @@ pub(crate) struct AnimationInput {
}
impl AnimationInput {
pub(crate) fn adopt_mode_settings(&mut self, other: Self) { self.height_overrides = other.height_overrides; }
pub fn load(data: &Collections, frames: &AnimationFrames, mode: &str) -> Result<Self, String> {
let right_toe = frames
.bone_names
@@ -19,6 +19,7 @@ pub(crate) struct Runtime {
chromosome: grind_chromosome::Chromosome,
}
impl Runtime {
pub(crate) fn adopt_mode_settings(&mut self, other: Self) { self.settings = other.settings; }
pub fn load(data: &Collections) -> Result<Self, String> {
Ok(Self {
states: [State::new(); 6],
@@ -118,7 +118,7 @@ impl GroundSettings {
propulsion: GroundPropulsionInput {
flags_2468: p.flags_2468,
flags_2472: p.flags_2472,
target_speed: a.contacts.push_speed * self.push_target_multiplier,
target_speed: push_target(a.contacts.push_speed, p.scalar_2612, self.push_target_multiplier),
signed_speed: p.scalar_2612,
absolute_body_speed: t.absolute_speed,
scalar_2660: t.total_mass,
@@ -272,3 +272,28 @@ fn xyz(v: [f32; 4]) -> Vector3 {
fn v(v: Vector3) -> [f32; 4] {
[v.x, v.y, v.z, 0.0]
}
/// Scale the animation-requested speed *gain*, not the existing velocity.
/// Preserve the exact native target at 1x; caps/contacts still belong to core.
fn push_target(target:f32,current:f32,strength:f32)->f32 {
if strength==1. {target} else {current+(target-current).max(0.)*strength}
}
#[cfg(test)]
mod custom_push_tests {
use super::push_target;
use skate_core::{math::Vector3,riding::push::{calculate_acceleration,PushInput,PushLimits}};
#[test]
fn higher_limits_alone_do_not_increase_animation_requested_push() {
let force=|strength,cap|calculate_acceleration(PushInput {
flags_2468:0x0200_0000,flags_2472:0,target_speed:push_target(3.2,3.,strength),
current_speed:3.,absolute_body_speed:3.,scale:80.,delta_seconds:1./60.,direction:Vector3::new(0.,0.,1.),
},PushLimits {maximum_pushable_speed:8.5,low_speed_change:cap,high_speed_change:cap}).vector.z;
assert_eq!(force(1.,1.5),force(1.,10.));
assert!((force(5.,10.)/force(1.,10.)-5.).abs()<0.001);
assert_eq!(force(0.,10.),0.);
assert_eq!(push_target(3.2,5.,10.),5.);
assert_eq!(push_target(3.2,5.,1.).to_bits(),3.2f32.to_bits());
}
}
@@ -77,7 +77,11 @@ impl GroundSettings {
//broadcasts82165A10 (zero) into830BD380 for speed override direction.
let threshold = [f32::from_bits(0x3586_37bd); 4];
Ok(Self {
push_target_multiplier: 1.,
// Only Custom overrides the animation-requested push gain. Original
// profiles and raw stock test collections keep the native 1x path.
push_target_multiplier: if mode=="test" && data.field("physics_mode",mode,"HostPushStrength").is_ok() {
m("HostPushStrength")?
} else {1.},
foot_force_offset: f("physics_feet", "FootForceOffset")?,
absorption_front: f("physics_feet", "AbsorptionFootForceScalar")?,
absorption_rear: f("physics_feet", "AbsorptionFootForceRearScalar")?,
@@ -34,6 +34,7 @@ pub(crate) struct GroundState {
pub(super) entry_settings: super::entry::EntrySettings,
}
impl GroundState {
pub(crate) fn adopt_mode_settings(&mut self, other: Self) { self.auto_push_enabled = other.auto_push_enabled; self.pumping_settings = other.pumping_settings; }
pub fn load(data: &Collections, _mode: &str, human_player: bool) -> Result<Self, String> {
let push = |field| data.float("physics_push", "default", field);
Ok(Self {
@@ -20,6 +20,7 @@ pub(crate) struct KnownAir {
flip_axis_adjustment: [f32; 4],
}
impl KnownAir {
pub(crate) fn adopt_mode_settings(&mut self, other: Self) { self.modes = other.modes; }
pub fn load(data: &Collections) -> Result<Self, String> {
let (settings, modes) = settings::load(data)?;
Ok(Self {
+28 -1
View File
@@ -102,6 +102,33 @@ pub(crate) struct SkaterRuntime {
}
impl SkaterRuntime {
/// Prepare all settings before committing; never replace active physics state.
pub(crate) fn reload_difficulty(&mut self, data: &Collections) -> Result<(), String> {
let profiles = super::ground_runtime::GroundProfiles::load(data)?;
let ground_settings = profiles.select(self.player_input.processed.state_variant_index_2528,
self.player_input.processed.surface_mode_2540.clamp(1, 5))?;
let ground = GroundState::load(data, "test", true)?;
let air = super::air_phase::AirSettings::load(data)?;
let reckoning = super::air_reckoning::AirReckoning::load(data)?;
let known = super::known_air::KnownAir::load(data)?;
let wipeout = super::wipeout::Wipeout::load(data)?;
let grind = super::grind::Runtime::load(data)?;
let jumps = super::ground_animation::GroundAnimationSettings::load(data)?;
let animation = AnimationInput::load(data, &self.animation.evaluator.frames, "test")?;
let player = PlayerInputRuntime::load(data)?;
self.ground_profiles = profiles; self.ground_settings = ground_settings;
self.ground.adopt_mode_settings(ground);
self.air_settings = air;
self.air_reckoning.adopt_mode_settings(reckoning);
self.known_air.adopt_mode_settings(known);
self.wipeout.adopt_mode_settings(wipeout);
self.grind.adopt_mode_settings(grind);
self.ground_animation_settings = jumps;
self.animation_input.adopt_mode_settings(animation);
self.player_input.player.state_variants_1408 = player.player.state_variants_1408;
Ok(())
}
pub(crate) fn travel_to(&mut self, transform: [[f32; 4]; 4]) -> Result<(), String> {
self.travel(transform, None)
}
@@ -128,7 +155,7 @@ impl SkaterRuntime {
asset_root: &Path, graphs: &StockGraphs, physics: &GamePhysics, mode: &str,
source: Option<std::sync::Arc<crate::skater_animation::AnimationSource>>,
) -> Result<Self, String> {
let data = Collections::load(asset_root)?;
let data = crate::custom_difficulty::load_collections(asset_root)?;
let banks = skate_data::animation_banks::AnimationBanks::load(asset_root)?;
let animation_metadata = banks.metadata()?;
// The host's current character is a custom skater with no pro selector
+1
View File
@@ -13,6 +13,7 @@ pub(crate) struct Wipeout {
modes: [Mode; 5],
}
impl Wipeout {
pub(crate) fn adopt_mode_settings(&mut self, other: Self) { self.modes = other.modes; }
pub fn check_air_collision(
&mut self,
p: &ProcessedPhysicsInput,
+97 -4
View File
@@ -7,7 +7,7 @@ use crate::difficulty::Difficulty;
fn stock_difficulties_switch_without_resetting_the_player() {
let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT");
let root = std::path::Path::new(&root);
let data = Collections::load(root).unwrap();
let data = crate::custom_difficulty::load_collections(root).unwrap();
let assets = skate_data::GameAssets::load(root).unwrap();
let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap();
let mut physics = GamePhysics::load_with_difficulty(root, None, Difficulty::Easy).unwrap();
@@ -24,15 +24,15 @@ fn stock_difficulties_switch_without_resetting_the_player() {
assert_eq!(settings.wheel_material.dynamic_friction.to_bits(),
data.float("physics_surfaces", key, "WheelDynamicFriction").unwrap().to_bits());
assert_eq!(settings.board().propulsion.mode_speed_changes.map(f32::to_bits),
["MaxPushDVStart", "MaxPushDVEnd"].map(|f| data.float("physics_mode", mode.key(), f).unwrap().to_bits()));
["MaxPushDVStart", "MaxPushDVEnd"].map(|f| data.float("physics_mode", mode.profile_key(), f).unwrap().to_bits()));
assert_eq!(settings.wobble_amplitude.to_bits(),
data.float("physics_mode", mode.key(), "Hash_5B57F2CCCCEEF430").unwrap().to_bits());
data.float("physics_mode", mode.profile_key(), "Hash_5B57F2CCCCEEF430").unwrap().to_bits());
}
}
assert!(skater.ground_profiles.select(5, 1).is_err());
assert!(skater.ground_profiles.select(0, 0).is_err());
for mode in [Difficulty::Easy, Difficulty::Normal, Difficulty::Hardcore, Difficulty::Easy] {
for mode in [Difficulty::Easy, Difficulty::Normal, Difficulty::Hardcore, Difficulty::Motorized, Difficulty::Custom, Difficulty::Easy] {
let before = physics.board.bodies().map(|b| (b.rates.position, b.rates.linear_velocity));
let ticks = physics.ticks;
let generation = skater.pose_generation;
@@ -56,3 +56,96 @@ fn stock_difficulties_switch_without_resetting_the_player() {
assert_eq!(physics.ticks, ticks + 60);
}
}
#[test]
#[ignore = "requires private stock animation banks and collections"]
fn motorized_rb_accelerates_and_custom_settings_reload_without_reset() {
let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT");
let root = std::path::Path::new(&root);
let data = crate::custom_difficulty::load_collections(root).unwrap();
let assets = skate_data::GameAssets::load(root).unwrap();
let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap();
let mut speeds = Vec::new();
for held in [false, true] {
let mut physics = GamePhysics::load_with_difficulty(root, None, Difficulty::Motorized).unwrap();
let mut skater = SkaterRuntime::load(root, &graphs, &physics, "motorized").unwrap();
let mut controls = PlayerControls::load(root).unwrap();
let mut input = crate::input::ControllerInput::default();
let mut camera = crate::camera::CameraRuntime::load(root).unwrap();
let mut active = false;
let mut peak = 0.0f32;
for tick in 0..180 {
input.sample_raw_for_test(skate_core::input::xbox::XboxState {
buttons: if held && tick >= 30 {0x0200} else {0},
triggers: [0;2], left:[0;2], right:[0;2],
});
let mut actions = input.player_actions();
controls.update(&mut actions, physics.settings.step.simulation.time_step,
physics.settings.input_magnitude_threshold, skater.player_input.physical.scoring.capabilities_204);
controls.publish_gestures(physics.difficulty_index(), skater.player_input.physical.state.state_16);
frame::advance(&mut physics, &mut skater, &mut controls, &graphs, &mut actions, true, &mut camera).unwrap();
active |= skater.player_input.processed.flags_2476 & 0x0040_0000 != 0;
peak = peak.max(physics.riding.motion.forward_speed.abs());
}
assert_eq!(active, held, "RB must reach the native motor activation flag");
speeds.push(peak);
if held {
let mut tuning = crate::custom_difficulty::Tuning::defaults(&data).unwrap();
tuning.values.insert("MotorTopSpeed".into(), 123.);
tuning.values.insert("MotorEnabled".into(), 1.);
let mut custom_data = data.clone(); tuning.overlay(&mut custom_data).unwrap();
let before = physics.board.bodies().map(|b|(b.rates.position,b.rates.linear_velocity));
let generation = skater.pose_generation;
skater.reload_difficulty(&custom_data).unwrap();
physics.set_difficulty(Difficulty::Custom);
assert_eq!(before,physics.board.bodies().map(|b|(b.rates.position,b.rates.linear_velocity)));
assert_eq!(generation,skater.pose_generation);
let settings=skater.ground_profiles.select(4,1).unwrap();
assert_eq!(settings.board().speed_model.override_speed,123.);
assert!(settings.board().speed_model.override_enabled);
}
}
eprintln!("Motorized peak speed: RB released={} m/s; RB held={} m/s",speeds[0],speeds[1]);
assert!(speeds[1]>speeds[0]+1., "Holding RB must produce meaningful acceleration");
}
#[test]
#[ignore = "requires private stock animation banks and collections"]
fn custom_push_strength_changes_real_push_acceleration() {
let root=std::env::var_os("SKATE3_ASSET_ROOT").unwrap();let root=std::path::Path::new(&root);
let data=crate::custom_difficulty::load_collections(root).unwrap();
let assets=skate_data::GameAssets::load(root).unwrap();
let graphs=crate::graph_runtime::StockGraphs::load(root,&assets).unwrap();
let mut results=Vec::new();
for strength in [1.,5.] {
let mut physics=GamePhysics::load_with_difficulty(root,None,Difficulty::Custom).unwrap();
let mut skater=SkaterRuntime::load(root,&graphs,&physics,"test").unwrap();
let mut tuning=crate::custom_difficulty::Tuning::defaults(&data).unwrap();
tuning.values.insert("HostPushStrength".into(),strength);
tuning.values.insert("MaxPushDVStart".into(),10.);
tuning.values.insert("MaxPushDVEnd".into(),10.);
let mut modified=data.clone();tuning.overlay(&mut modified).unwrap();skater.reload_difficulty(&modified).unwrap();
let mut controls=PlayerControls::load(root).unwrap();
let mut input=crate::input::ControllerInput::default();
let mut camera=crate::camera::CameraRuntime::load(root).unwrap();
let mut peak=0.0f32;let mut push_frames=0;
for tick in 0..100 {
input.sample_raw_for_test(skate_core::input::xbox::XboxState {
buttons:if (30..45).contains(&tick) {0x1000} else {0},triggers:[0;2],left:[0;2],right:[0;2],
});
let mut actions=input.player_actions();
controls.update(&mut actions,physics.settings.step.simulation.time_step,
physics.settings.input_magnitude_threshold,skater.player_input.physical.scoring.capabilities_204);
controls.publish_gestures(physics.difficulty_index(),skater.player_input.physical.state.state_16);
frame::advance(&mut physics,&mut skater,&mut controls,&graphs,&mut actions,true,&mut camera).unwrap();
peak=peak.max(physics.riding.motion.forward_speed.abs());
if skater.player_input.processed.flags_2468 & 0x0200_0000 != 0 {push_frames+=1;}
}
assert!(push_frames>0,"controller input must generate actual animation push events");
results.push(peak);
}
eprintln!("Actual push peak speeds: 1x={} m/s, 5x={} m/s",results[0],results[1]);
assert!(results[1]>results[0]*1.15,"stronger pushes must accelerate faster through real gameplay");
}
@@ -0,0 +1,38 @@
//! Manual actual-asset CPU probe. Does not launch a game or imply an FPS result.
use skate_dynamics::{BodyDesc,BodyType,DynamicsWorld,Shape,deformation::Options,definition_codec};
use std::{path::PathBuf,time::Instant};
fn run()->Result<(),String> {
let path=PathBuf::from(std::env::args().nth(1).ok_or("GLB path required")?);
let shape=skate_mods::model_shape_file(&path,"skyline_mesh",&Default::default())?;
let geometry=skate_mods::model::read_geometry_file(&path,"skyline_mesh",&Default::default())?;
let max_z=geometry.vertices.iter().map(|p|p[2]).fold(f32::MIN,f32::max);
for (label,width) in [("wall",8.),("post",0.16)] {
let mut w=DynamicsWorld::default();
w.spawn(BodyDesc {body_type:BodyType::Static,shape:Shape::Box {half_extents:[width,8.,0.25]},
position:[0.,20.,max_z+0.5],..Default::default()})?;
let t=Instant::now();
let id=w.spawn(BodyDesc {shape:shape.clone(),mass:1400.,position:[0.,20.,0.],ccd:true,
linear_damping:0.,angular_damping:0.,deformation:Some(Options::default()),
center_of_mass:[0.,-0.27,0.16],inertia_half_extents:Some([0.92,0.55,2.1]),..Default::default()})?;
println!("{label} deformation setup {:?}",t.elapsed());
w.set_linvel(id,[0.,0.,20.]);
let mut micros=Vec::new();let mut damages=Vec::new();let mut revision=0;
for _ in 0..240 {
let t=Instant::now();w.step(1./120.);let us=t.elapsed().as_secs_f64()*1e6;
let now=w.deformation(id).unwrap().revision;
if now!=revision {damages.push(us);revision=now;}
micros.push(us);
}
micros.sort_by(f64::total_cmp);
if revision==0 {return Err(format!("actual {label} crash did not deform"));}
let definition=w.body_definition(id).unwrap();let bytes=definition_codec::encode(&definition)?;
let decoded=definition_codec::decode(&bytes)?;
let mut replica=DynamicsWorld::default();let remote=replica.spawn_replica(&decoded)?;
if replica.deformation(remote).unwrap().offsets!=w.deformation(id).unwrap().offsets {return Err("replica mesh mismatch".into());}
println!("{label}: revisions={revision}, damage_ticks_us={damages:?}, physics_tick_p99_us={:.1}, max_us={:.1}, definition_bytes={}",
micros[237],micros[239],bytes.len());
}
println!("PASS actual Skyline model contacts, deformed solid export and replica field. Does not measure rendering or online latency.");
Ok(())
}
fn main(){if let Err(e)=run(){eprintln!("{e}");std::process::exit(1);}}
+1
View File
@@ -166,6 +166,7 @@ function sdk.graphics.mesh(key, opts)
opts = opts or {}
submit{
kind="graphics_mesh",
deform_nodes=(type(opts.deform_nodes)=="table" and next(opts.deform_nodes)) and opts.deform_nodes or nil,
key=key,
path=opts.path or "",
body=opts.body,
+4 -1
View File
@@ -90,6 +90,8 @@ impl NodeState {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct GraphicsDefinition {
#[serde(default)]
pub deform_nodes: Vec<String>,
pub path:String, pub body:Option<String>, pub color:[f32;3],
#[serde(default = "opaque")]
pub opacity:f32,
@@ -97,7 +99,8 @@ pub struct GraphicsDefinition {
fn opaque() -> f32 { 1. }
impl GraphicsDefinition {
pub fn validate(&self) -> bool {
valid_asset(&self.path) && self.body.as_deref().is_none_or(valid_key)
self.deform_nodes.len()<=64 && self.deform_nodes.iter().all(|s|valid_node(s))
&& valid_asset(&self.path) && self.body.as_deref().is_none_or(valid_key)
&& self.color.iter().all(|v| v.is_finite() && (0. ..=1.).contains(v))
&& self.opacity.is_finite() && (0. ..=1.).contains(&self.opacity)
}
+26 -1
View File
@@ -236,6 +236,8 @@ pub enum Command {
},
GraphicsMesh {
key: String,
#[serde(default)]
deform_nodes: Vec<String>,
/// Package-relative GLB path, or empty for a debug box.
#[serde(default)]
path: String,
@@ -398,7 +400,7 @@ impl Command {
crate::schema::valid_id(key) && options.validate()
}
Self::PhysicsSpawn { key, body } => {
crate::schema::valid_id(key) && serde_json::to_value(body).is_ok()
crate::schema::valid_id(key) && body.deformation.as_ref().is_none_or(|o|o.validate().is_ok()) && serde_json::to_value(body).is_ok()
}
Self::PhysicsRemove { key }
| Self::PhysicsRemoveJoint { key }
@@ -547,10 +549,12 @@ impl Command {
scale,
color,
opacity,
deform_nodes,
..
} => {
crate::schema::valid_id(key)
&& crate::scene::valid_asset(path)
&& deform_nodes.len()<=64 && deform_nodes.iter().all(|s|crate::scene::valid_node(s))
&& body.as_ref().is_none_or(|b| crate::schema::valid_id(b))
&& position.as_ref().is_none_or(|p| point(p))
&& rotation.as_ref().is_none_or(|q| quat(q))
@@ -900,6 +904,7 @@ impl Vm {
let capabilities = lua.create_table()?;
capabilities.set("solid_bridge", 3)?;
capabilities.set("model_collision", 1)?;
capabilities.set("deformation", 1)?;
capabilities.set("physics_debug", 1)?;
capabilities.set("scene_transforms", 2)?;
capabilities.set("skater", 4)?;
@@ -1686,3 +1691,23 @@ mod snapshot_performance_tests {
eprintln!("SNAPSHOT_BENCH bytes={} mods={mods} iterations={iterations} eager_ms_per_frame={:.3} lazy_ms_per_frame={:.3} ratio={:.1}",serde_json::to_vec(&*snapshot).unwrap().len(),eager.as_secs_f64()*1000./iterations as f64,lazy.as_secs_f64()*1000./iterations as f64,eager.as_secs_f64()/lazy.as_secs_f64());
}
}
#[cfg(test)]
mod deformation_api_tests {
use super::*;
#[test]
fn generic_deformation_descriptors_cross_lua_command_boundary() {
let lua=Lua::new();
let value:mlua::Value=lua.load(r#"return {
kind='physics_spawn',key='metal_prop',body={
shape={type='box',half_extents={1,1,1}},body_type='dynamic',
deformation={yield_speed=3,compliance=0.03,resolution={9,5,17}}
}}"#).eval().unwrap();
let command:Command=lua.from_value(value).unwrap();
let Command::PhysicsSpawn {body,..}=command else {panic!()};
assert_eq!(body.deformation.unwrap().yield_speed,3.);
let command:Command=serde_json::from_value(serde_json::json!({"kind":"graphics_mesh","key":"visual",
"path":"prop.glb","body":"metal_prop","deform_nodes":["shell"]})).unwrap();
assert!(command.validate());
}
}
+9
View File
@@ -78,3 +78,12 @@ successful compilation; no unconditional no-penetration claim is made.
## 4.4.0 API migration
Requires the generalized engine API build. Chassis creation uses command results and waits for successful body creation before graphics and effects are bound. Failed creation reports the host error and can be retried with F10. Driving parameters are unchanged.
## Impact deformation (4.5.0)
Requires `sdk.capabilities.deformation >= 1`. Hard impacts permanently deform the
`skyline_mesh` node and the chassis collider using the engine's general deformation
field. Wheels keep their existing animated rigid transforms. The GLB materials,
lighting and shadows remain active. Re-spawn the car to repair it. All multiplayer
peers need this package and the updated engine. See `sdk/DEFORMATION.md`.
+3 -3
View File
@@ -8,9 +8,9 @@ a11335deddf0ac82e67781cc46a688974fa4e6955e966a839b69aeb41c009061 audio/pop.wav
66c5bbc42632641a211b2cec02fce7669e4623e4469598744509e526b2b13edc audio/synthesize.py
4ab8b076191b5c226bb8261684a8c7be10260e1012bf80b2f81f5fb08bc92fe1 audio/turbo_loop.wav
79e8b4cb1de4f2b9fad682d42b133fee5abc957de1afa27154ac9ac62b25dc24 fallback_car.glb
408da5b6915244a1c3885b31655311485e59d0d79fb9c5009a8f3400d6278395 main.lua
49773c208ec65ffe69e577ce7fd6e0c22cb01aca3910d5fa9d657533c42e8466 mod.json
3be337826f20b9002617d96c1b42ad0c9527948d940d817341b747d6834f8843 README.md
82f032738b5d8e57ba9de38224182bafdd4650fb2127e54d13e1d257cc93f1c4 main.lua
8b62ac0b6b150a5df300c2fc05beab63202af0b1d8fbab7fbcb2e90ca9809469 mod.json
594ff436c5c6f74eb672a7e3d4fc41bcd63a93423d5c3b8190266752c5fd7af8 README.md
0596747c6ced1b93ce94e02618c4e0122799c6b2f7e63eae76b895b4d0241713 skyline.glb
0e57bc34b1e21a698ba51cdd9b1b591688af3264dd57ecdd6e2c2b3184fdbe30 textures/skid_tread.png
eea004bbe843d4c0c806df71914dcc7f6ea5835052a55213b8bf78b89c68e40f textures/smoke_soft.png
+8 -4
View File
@@ -1323,6 +1323,7 @@ local function spawn_rig(request)
sdk.commands.request("spawn_chassis",{kind="physics_spawn",key=BODY,body={
shape={type="model",path=MODEL,object="skyline_mesh",
options={max_hulls=32,resolution=96,concavity=0.0025}},
deformation={yield_speed=0.5,compliance=0.15,radius=1.2,max_displacement=0.80,max_step=0.40,resolution={9,5,17}},
body_type="dynamic",mass=C.mass,position={position[1],height,position[3]},
heading=heading,friction=0.25,ccd=true,
-- Native assembly classification; the SDK treats this as metadata.
@@ -1341,7 +1342,7 @@ local function confirm_spawn()
if not pending then return end
local result=sdk.commands.result("spawn_chassis")
if result and result.ok and sdk.bodies.read({kind="mod",key=BODY}) then
sdk.graphics.mesh("skyline_visual",{path=MODEL,body=BODY})
sdk.graphics.mesh("skyline_visual",{path=MODEL,body=BODY,deform_nodes={"skyline_mesh"}})
prepare_effects()
state.spawn_confirm=nil
state.spawned=true
@@ -1909,18 +1910,21 @@ return {
-- Replacing only the Lua wrapper must NOT enable unknown native commands.
local native=sdk.capabilities or {}
if not native.engine_access or not native.command_results then error("Skyline requires the generalized engine API build") end
if (native.deformation or 0)<1 then
error("Skyline 4.5.0 requires the deformation engine update. Close the game and relaunch play.bat after the rebuild is installed.")
end
if (native.model_collision or 0)<1 or (native.solid_bridge or 0)<3
or (native.physics_debug or 0)<1 or (native.scene_transforms or 0)<2 then
error("Skyline 4.4.0: the running executable is missing the native Model Collision Repair API. "..
error("Skyline 4.5.0: the running executable is missing the native Model Collision Repair API. "..
"Install the complete matching crates update and launch a successfully rebuilt game executable. "..
"Copying only api.lua is not sufficient.")
end
if not sdk.graphics or (sdk.graphics.version or 0)<2 or not sdk.player.detaching then
error("Skyline 4.4.0: the embedded Lua SDK wrapper does not match the native Model Collision Repair API")
error("Skyline 4.5.0: the embedded Lua SDK wrapper does not match the native Model Collision Repair API")
end
reset_simulation(); remove_rig(); prepare_audio(); prepare_presentation(); ensure_vfx_mesh()
set_collision_debug(sdk.settings.show_collision_hull==true)
sdk.log("Skyline 4.4.0: render-model compound collision, native impacts, animated wheels, safe exits; J toggles actual colliders")
sdk.log("Skyline 4.5.0: geometry-shaped deformation, model collision, native impacts, animated wheels, safe exits; J toggles actual colliders")
sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","")
end,
on_update=function(event)
+2 -2
View File
@@ -2,9 +2,9 @@
"id": "examples.skyline",
"api": 2,
"name": "Skyline DRIVE \u2014 Solid Bridge",
"version": "4.4.0",
"version": "4.5.0",
"author": "Skate3clone",
"description": "Generalized engine API integration with acknowledged chassis spawning; model collision, native impacts, animated wheels and safe exits. Driving tuning is unchanged.",
"description": "General engine deformation: obstacle-shaped chassis dents update GLB meshes and solid collision, with owner-authoritative replication. Animated wheels remain rigid.",
"entry": "main.lua",
"settings": {
"tire_friction": {
+107
View File
@@ -0,0 +1,107 @@
# Generic impact deformation
Feature discovery: `sdk.capabilities.deformation == 1`. Manifest API stays 2.
This is an impact-driven plastic deformation approximation on top of rigid-body
physics. It is not a complete node/beam soft-body solver or fracture simulation.
The engine has no vehicle, panel, bumper or wheel rules.
```lua
sdk.physics.spawn("object", {
body_type="dynamic", mass=100,
shape={type="model", path="object.glb", object="shell"},
deformation={
yield_speed=2, compliance=0.045, radius=1.2,
max_displacement=0.55, max_step=0.22, cooldown=0.10,
resolution={9,5,17},
},
})
sdk.graphics.mesh("visual", {
path="object.glb", body="object", deform_nodes={"shell"},
})
```
`deformation` is optional on any box, convex or resolved model/compound body.
Sensors cannot deform. The mod supplies which scene node names (including their
descendants) follow the body field through `deform_nodes`. An empty list keeps
graphics rigid. Bind static GLB node transforms; animate other nodes separately.
Match the model-collider scale with the graphics instance scale.
| Parameter | Meaning / valid range |
| --- | --- |
| yield_speed | Normal contact impulse divided by this body's mass; m/s, 0.5–100. This is a gameplay yield criterion, not a measured material stress. |
| compliance | Metres of crush per m/s above yield, 0.001–0.2. |
| radius | Maximum lateral impact region in metres, 0.1–5. Actual obstacle surface queries determine which samples move. |
| max_displacement | Maximum cumulative offset from each lattice point's rest position, 0.01–2 m. |
| max_step | Maximum dent depth per accepted impact, 0.001–max_displacement m. |
| cooldown | Minimum interval between damage updates, 0.05–2 seconds. |
| resolution | Lattice counts on X/Y/Z, each 2–25, at most 2048 nodes total. |
Defaults are shown above. Recreating the body repairs it; there is no automatic
healing. Body pose updates alone retain damage. Mass, authored inertia, velocity,
joints, extra colliders and body identity are preserved during a dent. Extra
colliders remain rigid; only the primary shape deforms.
## Geometry and performance
The strongest qualifying contact pair is processed per body/substep. Its solved
normal impulses select the depth; rays against the other collider select the
obstacle footprint. Thus a narrow post and a broad wall produce different dents.
Queries use the actual map triangle mesh or the other object's solid collider,
including rotation. Physics-deformation fields use collider-local coordinates;
render bindings account for node hierarchy, graphics transform and collider offset.
At creation, large convex pieces are split into smaller convex regions, bounded
by the existing 64-part/8192-vertex limits. The preparation is cached for repeated
spawns. On impact, only changed convex pieces are rebuilt; there is no runtime
VHACD. The resulting shared shapes are used by Rapier and the native skater bridge.
Both geometry representations sample the same field, but convex pieces and grid
resolution still approximate small dents. This is not triangle-exact collision.
Visual vertices cache their eight lattice weights. GLB meshes become private to
the instance only when affected, retaining materials, UVs and shadow participation.
Changed primitives update normals/tangents and culling bounds. Unchanged damage
does no mesh work; unaffected primitives skip cloning and uploading. Damage work
is bounded native CPU work on the simulation/render update threads, not Lua
vertex loops, GPU readback, or an always-running soft-body simulation.
## Multiplayer
Only the owner computes damage. Body definitions carry the resulting solid
geometry and the lossless binary lattice offsets in the existing authenticated,
fragmented durable application state. Geometry/field share one revision; late
joiners receive the current state. Receivers retain and move their previous solid
until all new fragments are available. Damage-only revisions update its collider
in place. Remote objects do not independently accumulate damage.
Compact body-definition encoding is now SBD4. Every peer needs the rebuilt engine
and matching mod package. Existing envelope and packet budgets still apply;
oversized definitions are rejected, not silently simplified. Network latency can
delay remote damage until that revision is complete.
## Research basis and deliberate differences
- [BeamNG physics](https://www.beamng.com/game/about/physics/) and
[flexbody documentation](https://docs.beamng.com/modding/vehicle/sections/flexbodies/):
a lower-resolution deforming structure drives the visible mesh; permanent yield
is distinct from elastic motion and breakage. We use a shared lattice and
permanent displacement, without implementing BeamNG's spring/mass simulation.
- Victor Cepeda's Saints Row vehicle deformation article, *Game Developer*,
[March 2012](https://media.gdcvault.com/GD_Mag_Archives/GDM_March_2012.pdf):
simpler collision pieces can be linked to higher-detail visual damage. We warp
convex pieces instead of using their removable collision-piece/morph system.
- [Rapier contact documentation](https://rapier.rs/docs/user_guides/templates_injected/advanced_collision_detection/):
use solved impulses and manifold geometry. The pinned Rapier 0.35.3 uses
body-local solver anchors; this implementation resolves them through
`solver_contact_world_points`, rather than assuming a world-space `point` field.
## Validation tools
`cargo test -p skate-dynamics deformation` covers obstacle shape, rotated impacts,
resting-contact rejection, solid/mesh agreement, bounded damage, wire round-trip,
late join and in-place replica updates. `cargo test -p skate-game deformation`
checks mesh isolation, materials, rigid-node exclusion and rebinding.
`cargo run -p skate-mods --example probe_deformation -- mods/Skyline_Drive_Mod/skyline.glb`
runs actual-model wall/post collisions and prints CPU timings. The ignored
`actual_asset_deformation_cpu_cost` game test times the welded asset mesh path.
These are CPU probes, not an in-game FPS or live multiplayer measurement.
+4
View File
@@ -5,6 +5,10 @@ Manifest API remains `2`. Feature discovery uses compiled `sdk.capabilities`:
`input_override=1`, `player_physics=2`, `player_overlap=1`, `landed_details=1`.
The host has no injury, vehicle or challenge rules. Those live in Lua.
`deformation=1` adds optional impact-driven mesh/collider deformation.
See [DEFORMATION.md](DEFORMATION.md) for generic body parameters, scene-node
bindings, performance limits and multiplayer behavior.
## Reading systems
`sdk.engine.systems()` lists available system names. `sdk.engine.read(name)`
+10
View File
@@ -2,7 +2,16 @@
-- SDK 2 language-server declarations. Not executed at runtime.
---@alias Vec3 number[]
---@alias Quat number[] xyzw
---@class DeformationOptions
---@field yield_speed? number contact impulse/body mass threshold, m/s (default 2)
---@field compliance? number metres per m/s above yield (default 0.045)
---@field radius? number impact region metres (default 1.2)
---@field max_displacement? number cumulative offset limit metres (default 0.55)
---@field max_step? number per-impact crush metres (default 0.22)
---@field cooldown? number minimum update interval seconds (default 0.10)
---@field resolution? integer[] XYZ lattice counts, default {9,5,17}; <=2048 total
---@class BodyDesc
---@field deformation? DeformationOptions
---@field shape {type:'box'|'sphere'|'capsule'|'convex'|'mesh', half_extents?:Vec3, radius?:number, half_height?:number, points?:Vec3[], path?:string, object?:string}
---@field body_type 'dynamic'|'kinematic'|'static'
---@field mass? number
@@ -230,6 +239,7 @@ function sdk.physics.contacts() end
function sdk.physics.touching() end
---@param key string
---@param opts {path?:string, body?:string, position?:Vec3, rotation?:Quat, scale?:Vec3, color?:Vec3, visible?:boolean, opacity?:number}
-- opts.deform_nodes: optional string[] of GLB scene node names whose meshes follow the bound body deformation.
function sdk.graphics.mesh(key, opts) end
---@param key string
function sdk.graphics.remove(key) end