use bevy::math::Vec3;
use std::f32::consts::TAU;
use crate::CarnageSettings;
use crate::soup::{WELD, hash_f32, plane_basis};
use crate::wound::Wound;
pub const BLOOD_DENSITY: f32 = 1060.0;
pub const BLOOD_SURFACE_TENSION: f32 = 0.060_45;
pub const FORWARD_SPATTER_SPEED: f32 = 40.0;
pub const BACK_SPATTER_SPEED: f32 = 8.0;
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Droplet {
pub dir: Vec3,
pub speed: f32,
pub diameter: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Stain {
pub at: Vec3,
pub radius: f32,
pub seed: u32,
}
pub fn wound_seed(w: &Wound) -> u32 {
let q = |x: f32| (x / WELD).round() as i64 as u32;
q(w.at.x)
^ q(w.at.y).wrapping_mul(0x9E37_79B9)
^ q(w.at.z).wrapping_mul(2_654_435_761)
^ (w.kind as u32).wrapping_mul(0x85EB_CA6B)
}
pub fn droplet_count(w: &Wound, s: &CarnageSettings) -> u32 {
if !(w.area > 0.0) || !(w.severity > 0.0) || !w.area.is_finite() {
return 0;
}
let n = w.area * s.droplets_per_m2 * w.severity.clamp(0.0, 1.0);
if !n.is_finite() {
return 0;
}
(n.round().max(0.0) as u32).min(s.max_droplets_per_wound)
}
pub fn droplet(w: &Wound, index: u32, s: &CarnageSettings) -> Droplet {
Spray::of(w, s).droplet(index, s)
}
#[derive(Clone, Copy)]
struct Spray {
axis: Vec3,
tangent: Vec3,
bitangent: Vec3,
theta_max: f32,
seed: u32,
}
impl Spray {
fn of(w: &Wound, s: &CarnageSettings) -> Self {
let axis = w.normal.normalize_or_zero();
let (tangent, bitangent) = plane_basis(axis);
Self {
axis,
tangent,
bitangent,
theta_max: s.spatter_cone_deg.to_radians(),
seed: wound_seed(w),
}
}
fn droplet(&self, index: u32, s: &CarnageSettings) -> Droplet {
let key = self.seed ^ index.wrapping_mul(0x9E37_79B9);
let t = hash_f32(key);
let u = hash_f32(key ^ 0x85EB_CA6B);
let v = hash_f32(key ^ 0xC2B2_AE35);
let diameter = s.droplet_size_min + (s.droplet_size_max - s.droplet_size_min) * t;
let speed = (FORWARD_SPATTER_SPEED + (BACK_SPATTER_SPEED - FORWARD_SPATTER_SPEED) * t)
* s.spatter_speed_scale;
let phi = TAU * u;
let theta = self.theta_max * v.clamp(0.0, 1.0).sqrt();
let dir = (self.axis * theta.cos()
+ (self.tangent * phi.cos() + self.bitangent * phi.sin()) * theta.sin())
.normalize_or_zero();
Droplet { dir, speed, diameter }
}
}
pub fn droplets(w: &Wound, s: &CarnageSettings) -> Vec<Droplet> {
let spray = Spray::of(w, s);
let n = droplet_count(w, s);
let mut out = Vec::with_capacity(n as usize);
for i in 0..n {
out.push(spray.droplet(i, s));
}
out
}
pub fn landing(from: Vec3, d: &Droplet, gravity: f32, plane_y: f32) -> Option<Vec3> {
let h = from.y - plane_y;
if !(h > 0.0) || !h.is_finite() {
return None;
}
let vy = d.dir.y * d.speed;
let t = if gravity.abs() <= f32::EPSILON {
if vy >= 0.0 {
return None;
}
h / -vy
} else {
let disc = vy * vy + 2.0 * gravity * h;
if disc < 0.0 {
return None;
}
(vy + disc.sqrt()) / gravity
};
if !(t > 0.0) || !t.is_finite() {
return None;
}
let mut at = from + d.dir * d.speed * t;
at.y = plane_y;
Some(at)
}
pub fn stain_radius(d: &Droplet, impact_speed: f32, s: &CarnageSettings) -> f32 {
let span = (s.droplet_size_max - s.droplet_size_min).max(f32::MIN_POSITIVE);
let size_frac = ((d.diameter - s.droplet_size_min) / span).clamp(0.0, 1.0);
let speed_span = (FORWARD_SPATTER_SPEED - BACK_SPATTER_SPEED).max(f32::MIN_POSITIVE);
let speed_frac = ((impact_speed - BACK_SPATTER_SPEED) / speed_span).clamp(0.0, 1.0);
let frac = (0.7 * size_frac + 0.3 * speed_frac).clamp(0.0, 1.0);
s.stain_radius_min + (s.stain_radius_max - s.stain_radius_min) * frac
}
pub fn stains(w: &Wound, s: &CarnageSettings, plane_y: f32) -> Vec<Stain> {
let spray = Spray::of(w, s);
let fall = (w.at.y - plane_y).max(0.0);
let n = droplet_count(w, s);
let mut out = Vec::with_capacity(n as usize);
for i in 0..n {
let d = spray.droplet(i, s);
let Some(at) = landing(w.at, &d, s.gravity, plane_y) else {
continue;
};
let vy = d.dir.y * d.speed;
let impact = (vy * vy + 2.0 * s.gravity * fall).max(0.0).sqrt();
let horizontal = (d.dir * d.speed - Vec3::Y * vy).length();
let impact_speed = (impact * impact + horizontal * horizontal).sqrt();
out.push(Stain {
at,
radius: stain_radius(&d, impact_speed, s),
seed: spray.seed ^ i,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wound::WoundKind;
fn fixed_wound() -> Wound {
Wound {
at: Vec3::new(0.1, 0.9, -0.2),
normal: Vec3::X,
area: 0.004,
severity: 1.0,
kind: WoundKind::Severance,
}
}
#[test]
fn the_spatter_model_is_frozen() {
let w = fixed_wound();
let s = CarnageSettings::default();
assert_eq!(wound_seed(&w), 2_698_380_592, "the wound seed itself is part of the contract");
let expect: [([u32; 3], u32, u32); 8] = [
([0x3F6517F7, 0xBE1A5BEA, 0x3ED70FF4], 0x41F61DBD, 0x3B16C870),
([0x3F5EF5C2, 0x3EC30692, 0xBE9EF29A], 0x41948C24, 0x3B8C554F),
([0x3F6E4F76, 0x3EA1C3E5, 0x3E3BB593], 0x4162CCF4, 0x3BA3BA27),
([0x3F7D49A7, 0x3BBC9904, 0xBE148C9F], 0x420F2261, 0x3AC2AA04),
([0x3F616999, 0x3EF132EC, 0x3D573EAE], 0x41C13CCA, 0x3B5D2CD3),
([0x3F6F2E11, 0xBE44246A, 0xBE99F0F8], 0x41BF2962, 0x3B5FF03C),
([0x3F784E4D, 0x3D42166C, 0x3E74650E], 0x41CEE206, 0x3B4B02A2),
([0x3F73318E, 0x3E5C82D3, 0xBE67A60D], 0x4213499A, 0x3AAC8C8E),
];
let mut actual = Vec::new();
for i in 0..8u32 {
let d = droplet(&w, i, &s);
actual.push((
[d.dir.x.to_bits(), d.dir.y.to_bits(), d.dir.z.to_bits()],
d.speed.to_bits(),
d.diameter.to_bits(),
));
}
let rendered: Vec<String> = actual
.iter()
.map(|(dir, sp, di)| {
format!(
"([0x{:08X}, 0x{:08X}, 0x{:08X}], 0x{sp:08X}, 0x{di:08X}),",
dir[0], dir[1], dir[2]
)
})
.collect();
assert_eq!(
actual.as_slice(),
expect.as_slice(),
"the spatter model moved. If that was deliberate, the new bits are:\n{}",
rendered.join("\n")
);
}
#[test]
fn the_stain_placement_is_frozen() {
let w = fixed_wound();
let s = CarnageSettings::default();
let stains = stains(&w, &s, 0.0);
assert_eq!(stains.len(), 10, "area x density must give this many droplets");
let expect: [([u32; 3], u32); 4] = [
([0x40879314, 0x00000000, 0x3FDEEF19], 0x3D7E1738),
([0x4169C84C, 0x00000000, 0xC0ABEC1B], 0x3D9EE6FE),
([0x410B1577, 0x00000000, 0x3FBEFBA5], 0x3DAA9FCC),
([0x413B5528, 0x00000000, 0xBFF37568], 0x3D641D9F),
];
let actual: Vec<([u32; 3], u32)> = stains
.iter()
.take(4)
.map(|st| {
([st.at.x.to_bits(), st.at.y.to_bits(), st.at.z.to_bits()], st.radius.to_bits())
})
.collect();
let rendered: Vec<String> = actual
.iter()
.map(|(at, r)| {
format!("([0x{:08X}, 0x{:08X}, 0x{:08X}], 0x{r:08X}),", at[0], at[1], at[2])
})
.collect();
assert_eq!(
actual.as_slice(),
expect.as_slice(),
"stain placement moved. If that was deliberate, the new bits are:\n{}",
rendered.join("\n")
);
}
#[test]
fn size_and_speed_are_inversely_correlated() {
let w = fixed_wound();
let s = CarnageSettings::default();
let n = 256usize;
let d: Vec<Droplet> = (0..n as u32).map(|i| droplet(&w, i, &s)).collect();
let mean = |f: &dyn Fn(&Droplet) -> f32| d.iter().map(f).sum::<f32>() / n as f32;
let (md, ms) = (mean(&|x: &Droplet| x.diameter), mean(&|x: &Droplet| x.speed));
let mut cov = 0.0f64;
let (mut vd, mut vs) = (0.0f64, 0.0f64);
for x in &d {
let (a, b) = ((x.diameter - md) as f64, (x.speed - ms) as f64);
cov += a * b;
vd += a * a;
vs += b * b;
}
let r = cov / (vd.sqrt() * vs.sqrt());
assert!(
r < -0.9,
"diameter and speed correlate at r = {r:.4}, but the percolation model requires a \
strong inverse relation (r < -0.9) — small droplets leave fast, large ones leave slow. \
A spray without it reads as confetti."
);
}
#[test]
fn a_droplet_does_not_depend_on_the_ones_before_it() {
let w = fixed_wound();
let s = CarnageSettings::default();
let all = droplets(&w, &s);
for (i, d) in all.iter().enumerate() {
assert_eq!(*d, droplet(&w, i as u32, &s), "droplet {i} depends on its neighbours");
}
}
#[test]
fn every_droplet_leaves_inside_the_cone() {
let s = CarnageSettings::default();
for (nx, ny, nz) in [(1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, -1.0), (0.6, 0.8, 0.0)] {
let normal = Vec3::new(nx, ny, nz).normalize();
let w = Wound { normal, ..fixed_wound() };
let cos_cone = s.spatter_cone_deg.to_radians().cos();
for i in 0..512u32 {
let d = droplet(&w, i, &s);
assert!(
(d.dir.length() - 1.0).abs() < 1.0e-5,
"droplet {i} direction length {}",
d.dir.length()
);
assert!(
d.dir.dot(normal) >= cos_cone - 1.0e-4,
"droplet {i} left at {:.4} rad from the normal, outside the {} deg cone",
d.dir.dot(normal).acos(),
s.spatter_cone_deg
);
}
}
}
#[test]
fn the_seed_is_quantized_to_the_weld_lattice() {
let w = fixed_wound();
let nudged = Wound { at: w.at + Vec3::splat(WELD * 0.1), ..w };
assert_eq!(wound_seed(&w), wound_seed(&nudged), "a sub-lattice nudge must not move the seed");
let moved = Wound { at: w.at + Vec3::X * WELD * 40.0, ..w };
assert_ne!(wound_seed(&w), wound_seed(&moved), "a real move must be a different spray");
}
#[test]
fn the_kind_is_part_of_the_seed() {
let a = fixed_wound();
let b = Wound { kind: WoundKind::Channel, ..a };
assert_ne!(
wound_seed(&a),
wound_seed(&b),
"a cut and a bullet channel at one point must not spray identically"
);
}
#[test]
fn the_droplet_count_scales_with_area_and_clamps() {
let s = CarnageSettings::default();
let w = fixed_wound();
assert_eq!(droplet_count(&Wound { area: 0.0, ..w }, &s), 0, "no area, no blood");
assert_eq!(droplet_count(&Wound { severity: 0.0, ..w }, &s), 0, "clotted, no blood");
let small = droplet_count(&Wound { area: 0.001, ..w }, &s);
let big = droplet_count(&Wound { area: 0.01, ..w }, &s);
assert!(big > small, "a wider wound must throw more blood: {small} then {big}");
assert_eq!(
droplet_count(&Wound { area: 1.0e6, ..w }, &s),
s.max_droplets_per_wound,
"an enormous wound must be clamped to the authored ceiling"
);
assert_eq!(
droplet_count(&Wound { severity: 0.5, ..w }, &s) * 2,
droplet_count(&w, &s),
"half severity is half the blood"
);
}
#[test]
fn a_droplet_that_never_reaches_the_plane_has_no_landing() {
let s = CarnageSettings::default();
let up = Droplet { dir: Vec3::Y, speed: 10.0, diameter: 0.002 };
assert!(
landing(Vec3::new(0.0, 0.5, 0.0), &up, s.gravity, 0.5).is_none(),
"a droplet starting on the plane has not landed on it"
);
assert!(
landing(Vec3::new(0.0, 0.2, 0.0), &up, s.gravity, 0.5).is_none(),
"a droplet starting below the plane never lands on it"
);
assert!(
landing(Vec3::new(0.0, 1.0, 0.0), &up, 0.0, 0.0).is_none(),
"with no gravity an upward droplet never comes down"
);
let hit = landing(Vec3::new(0.0, 1.0, 0.0), &up, s.gravity, 0.0)
.expect("thrown up under gravity, it lands");
assert_eq!(hit.y, 0.0, "the landing must be exactly on the plane, not a float above it");
}
#[test]
fn blood_lands_downrange_of_the_wound() {
let s = CarnageSettings::default();
let w = fixed_wound();
let stains = stains(&w, &s, 0.0);
assert!(!stains.is_empty(), "a severity-1 wound of this area must stain the floor");
for st in &stains {
assert!(
st.at.x > w.at.x,
"a wound facing +X stained at x = {} which is not downrange of {}",
st.at.x,
w.at.x
);
assert!(
(s.stain_radius_min..=s.stain_radius_max).contains(&st.radius),
"stain radius {} is outside the authored range",
st.radius
);
}
}
#[test]
fn a_bigger_droplet_stains_wider() {
let s = CarnageSettings::default();
let small = Droplet { dir: Vec3::X, speed: 30.0, diameter: s.droplet_size_min };
let large = Droplet { dir: Vec3::X, speed: 30.0, diameter: s.droplet_size_max };
assert!(
stain_radius(&large, 30.0, &s) > stain_radius(&small, 30.0, &s),
"the larger droplet must leave the wider stain"
);
let slow = Droplet { dir: Vec3::X, speed: 8.0, diameter: 0.003 };
assert!(
stain_radius(&slow, FORWARD_SPATTER_SPEED, &s) > stain_radius(&slow, BACK_SPATTER_SPEED, &s),
"a faster impact must spread wider at the same size"
);
}
}