use std::collections::VecDeque;
use std::f32::consts::FRAC_PI_2;
use bevy::prelude::*;
use bevy_carnage::blood::dry::appearance;
use bevy_carnage::blood::stain::{Impact, impact_at_plane, stain_radius, stain_shape};
use bevy_carnage::blood::{self, Bleed, BloodSettings, Droplet, PatternClass, WoundKind, patterns};
const HZ: u32 = 60;
const FLOOR_HALF: f32 = 3.0;
const WALL_Z: f32 = -1.0;
const WALL_HALF_X: f32 = 1.8;
const WALL_TOP_Y: f32 = 2.4;
const WOUND_AREA: f32 = 0.05;
const IMPACT_AT: Vec3 = Vec3::new(0.15, 1.15, -0.25);
const IMPACT_NORMAL: Vec3 = Vec3::new(0.05, 0.72, -0.69);
const ARTERIAL_AT: Vec3 = Vec3::new(0.0, 1.45, -0.30);
const ARTERIAL_NORMAL: Vec3 = Vec3::new(0.0, 0.36, -0.93);
const BREATH_AT: Vec3 = Vec3::new(-0.20, 1.50, -0.10);
const BREATH_DIR: Vec3 = Vec3::new(-0.08, -0.18, -0.98);
const BREATH_ML: f32 = 2.0;
const BREATH_IMPULSE: f32 = 3.0;
const PIVOT: Vec3 = Vec3::new(0.30, 1.55, 0.10);
const ARM: f32 = 0.50;
const SWING_FROM_DEG: f32 = 100.0;
const SWING_TO_DEG: f32 = -30.0;
const SWING_TICKS: u32 = 12;
const SWING_PERIOD: u32 = 78;
const WALK_FROM: Vec3 = Vec3::new(-1.7, 0.0, 0.85);
const WALK_TO: Vec3 = Vec3::new(1.7, 0.0, 1.15);
const WALK_SPEEDS: [f32; 3] = [0.6, 1.4, 2.8];
const DRAG_FROM: Vec3 = Vec3::new(-1.6, 0.0, 0.30);
const DRAG_TO: Vec3 = Vec3::new(1.5, 0.0, -0.45);
const DRAG_SPEED: f32 = 0.30;
const CAST_OFF_LOAD_ML: f32 = 4.0;
const WALK_LOAD_ML: f32 = 1.5;
const DRAG_LOAD_ML: f32 = 6.0;
const MAX_STAINS: usize = 4000;
const LEGEND: &str = "1 impact spatter 2 arterial 3 cast-off 4 expirated \
5 drip trail 6 transfer C clear";
#[derive(Component)]
struct Readout;
#[derive(Resource)]
struct Blood {
settings: BloodSettings,
tick: u32,
active: Option<PatternClass>,
armed: u32,
pending: bool,
bleed: Bleed,
systoles: u32,
load: f32,
load_start: f32,
tip: Vec3,
swings: u32,
walk: usize,
walked: f32,
dripped: f32,
dragged: f32,
contacts: u32,
emitted: usize,
landed: usize,
rings: u8,
}
#[derive(Resource)]
struct Paint {
disc: Handle<Mesh>,
blood: Handle<StandardMaterial>,
stains: VecDeque<Entity>,
spawned: usize,
}
struct Hit {
at: Vec3,
normal: Vec3,
rotation: Quat,
long: f32,
short: f32,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "pattern_classes".into(),
canvas: Some("#carnage-canvas".into()),
..default()
}),
..default()
}))
.insert_resource(Time::<Fixed>::from_hz(f64::from(HZ)))
.add_systems(Startup, setup)
.add_systems(FixedUpdate, advance)
.add_systems(Update, (arm, markers, hud))
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
let mut settings = BloodSettings::default();
settings.spatter_speed_scale = 0.5;
let fresh = appearance(0, HZ, 1.0e-3, &settings);
let blood = materials.add(StandardMaterial {
base_color: Color::srgb(fresh.srgb[0], fresh.srgb[1], fresh.srgb[2]),
perceptual_roughness: fresh.roughness,
..default()
});
let disc = meshes.add(Circle::new(0.5).mesh().resolution(20));
commands.spawn((
Camera3d::default(),
Transform::from_xyz(2.25, 1.75, 2.65).looking_at(Vec3::new(0.0, 0.85, -0.30), Vec3::Y),
));
commands.insert_resource(GlobalAmbientLight {
color: Color::srgb(0.62, 0.66, 0.78),
brightness: 700.0,
..default()
});
commands.spawn((
DirectionalLight { illuminance: 8_000.0, shadow_maps_enabled: true, ..default() },
Transform::from_xyz(3.0, 6.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y),
));
let grey = materials.add(StandardMaterial {
base_color: Color::srgb(0.20, 0.20, 0.22),
perceptual_roughness: 0.95,
..default()
});
commands.spawn((
Mesh3d(meshes.add(Plane3d::default().mesh().size(FLOOR_HALF * 2.0, FLOOR_HALF * 2.0))),
MeshMaterial3d(grey.clone()),
));
commands.spawn((
Mesh3d(meshes.add(Rectangle::new(WALL_HALF_X * 2.0, WALL_TOP_Y))),
MeshMaterial3d(grey),
Transform::from_xyz(0.0, WALL_TOP_Y * 0.5, WALL_Z),
));
commands
.spawn(Node {
width: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(12.0)),
row_gap: Val::Px(8.0),
..default()
})
.with_children(|root| {
root.spawn((
Text::new(LEGEND),
TextFont { font_size: FontSize::Px(15.0), ..default() },
TextColor(Color::srgb(0.98, 0.72, 0.42)),
));
root.spawn((
Text::new(String::new()),
TextFont { font_size: FontSize::Px(13.0), ..default() },
TextColor(Color::srgb(0.88, 0.88, 0.92)),
Readout,
));
});
commands.insert_resource(Blood {
settings,
tick: 0,
active: None,
armed: 0,
pending: false,
bleed: Bleed::new(0, WOUND_AREA),
systoles: 0,
load: 0.0,
load_start: 0.0,
tip: PIVOT + swing_offset(0),
swings: 0,
walk: 0,
walked: 0.0,
dripped: 0.0,
dragged: 0.0,
contacts: 0,
emitted: 0,
landed: 0,
rings: 0,
});
commands.insert_resource(Paint { disc, blood, stains: VecDeque::new(), spawned: 0 });
}
fn arm(
keys: Res<ButtonInput<KeyCode>>,
mut blood: ResMut<Blood>,
mut paint: ResMut<Paint>,
mut commands: Commands,
) {
let picked = if keys.just_pressed(KeyCode::Digit1) {
Some(PatternClass::Impact)
} else if keys.just_pressed(KeyCode::Digit2) {
Some(PatternClass::ArterialSpurt)
} else if keys.just_pressed(KeyCode::Digit3) {
Some(PatternClass::CastOff)
} else if keys.just_pressed(KeyCode::Digit4) {
Some(PatternClass::Expirated)
} else if keys.just_pressed(KeyCode::Digit5) {
Some(PatternClass::DripTrail)
} else if keys.just_pressed(KeyCode::Digit6) {
Some(PatternClass::Transfer)
} else {
None
};
if keys.just_pressed(KeyCode::KeyC) {
blood.active = None;
for entity in paint.stains.drain(..) {
commands.entity(entity).despawn();
}
}
let Some(class) = picked else {
return;
};
if class == PatternClass::DripTrail && blood.active == Some(PatternClass::DripTrail) {
blood.walk = (blood.walk + 1) % WALK_SPEEDS.len();
}
let tick = blood.tick;
blood.active = Some(class);
blood.armed = tick;
blood.pending = true;
blood.bleed = Bleed::new(tick, WOUND_AREA);
blood.systoles = 0;
blood.swings = 0;
blood.tip = PIVOT + swing_offset(0);
blood.walked = 0.0;
blood.dripped = 0.0;
blood.dragged = 0.0;
blood.contacts = 0;
blood.emitted = 0;
blood.landed = 0;
blood.rings = 0;
blood.load = match class {
PatternClass::CastOff => CAST_OFF_LOAD_ML,
PatternClass::DripTrail => WALK_LOAD_ML,
PatternClass::Transfer => DRAG_LOAD_ML,
_ => 0.0,
};
blood.load_start = blood.load;
}
fn advance(mut blood: ResMut<Blood>, mut paint: ResMut<Paint>, mut commands: Commands) {
let b = &mut *blood;
b.tick = b.tick.wrapping_add(1);
let (Some(class), tick) = (b.active, b.tick) else {
return;
};
match class {
PatternClass::Impact => {
if !b.pending {
return;
}
b.pending = false;
let wound = wound_at(IMPACT_AT, IMPACT_NORMAL);
let drops = patterns::impact_spatter(&wound, &b.settings);
b.emitted = drops.len();
b.landed = paint_drops(&mut commands, &mut paint, &b.settings, IMPACT_AT, &drops);
}
PatternClass::ArterialSpurt => {
let wound = wound_at(ARTERIAL_AT, ARTERIAL_NORMAL);
let drops = patterns::arterial_arc(&wound, &b.bleed, tick, HZ, &b.settings);
if drops.is_empty() {
return;
}
b.systoles += 1;
b.emitted = drops.len();
let n = paint_drops(&mut commands, &mut paint, &b.settings, ARTERIAL_AT, &drops);
b.landed += n;
}
PatternClass::CastOff => {
let phase = tick.wrapping_sub(b.armed) % SWING_PERIOD.max(1);
if phase == 0 {
b.swings += 1;
}
let previous = b.tip;
b.tip = PIVOT + swing_offset(phase);
let drops =
patterns::cast_off(arr(previous), arr(b.tip), &mut b.load, HZ, &b.settings);
if drops.is_empty() {
return;
}
b.emitted = drops.len();
let n = paint_drops(&mut commands, &mut paint, &b.settings, b.tip, &drops);
b.landed += n;
}
PatternClass::Expirated => {
if !b.pending {
return;
}
b.pending = false;
let (drops, rings) =
patterns::expirated(arr(BREATH_DIR), BREATH_ML, BREATH_IMPULSE, tick, &b.settings);
b.emitted = drops.len();
b.rings = rings;
b.landed = paint_drops(&mut commands, &mut paint, &b.settings, BREATH_AT, &drops);
}
PatternClass::DripTrail => {
let speed = WALK_SPEEDS.get(b.walk).copied().unwrap_or(1.0);
let length = (WALK_TO - WALK_FROM).length();
if b.walked >= length {
return;
}
b.walked = (b.walked + speed / HZ as f32).min(length);
let from = along(WALK_FROM, WALK_TO, b.dripped);
let to = along(WALK_FROM, WALK_TO, b.walked);
let stains = patterns::drip_trail(arr(from), arr(to), speed, &mut b.load, &b.settings);
if stains.is_empty() {
return;
}
b.dripped = b.walked;
b.emitted = stains.len();
for stain in &stains {
let at = Vec3::new(stain.at[0], 0.0, stain.at[2]);
paint_flat(&mut commands, &mut paint, at, stain.radius);
b.landed += 1;
}
}
PatternClass::Transfer => {
let length = (DRAG_TO - DRAG_FROM).length();
if b.dragged >= length {
return;
}
let previous = along(DRAG_FROM, DRAG_TO, b.dragged);
b.dragged = (b.dragged + DRAG_SPEED / HZ as f32).min(length);
let contact = along(DRAG_FROM, DRAG_TO, b.dragged);
let tangent = (contact - previous).normalize_or_zero();
let Some(stain) =
patterns::transfer(arr(contact), arr(tangent), &mut b.load, &b.settings)
else {
return;
};
b.contacts += 1;
let at = Vec3::new(stain.at[0], 0.0, stain.at[2]);
paint_flat(&mut commands, &mut paint, at, stain.radius);
b.landed += 1;
}
}
}
fn markers(blood: Res<Blood>, mut gizmos: Gizmos) {
let Some(class) = blood.active else {
return;
};
let wound = Color::srgb(0.95, 0.35, 0.35);
let rig = Color::srgb(0.45, 0.85, 0.98);
match class {
PatternClass::Impact => cross(&mut gizmos, IMPACT_AT, 0.07, wound),
PatternClass::ArterialSpurt => cross(&mut gizmos, ARTERIAL_AT, 0.07, wound),
PatternClass::CastOff => {
gizmos.linestrip(
(0..=24).map(|i| PIVOT + swing_offset(i * SWING_TICKS.max(1) / 24)),
rig.with_alpha(0.35),
);
gizmos.line(PIVOT, blood.tip, rig);
cross(&mut gizmos, blood.tip, 0.05, wound);
}
PatternClass::Expirated => {
cross(&mut gizmos, BREATH_AT, 0.06, wound);
gizmos.line(BREATH_AT, BREATH_AT + BREATH_DIR.normalize_or_zero() * 0.35, rig);
}
PatternClass::DripTrail => {
let at = along(WALK_FROM, WALK_TO, blood.walked);
gizmos.line(WALK_FROM, WALK_TO, rig.with_alpha(0.3));
gizmos.line(at, at + Vec3::Y * 0.95, rig);
cross(&mut gizmos, at + Vec3::Y * 0.95, 0.06, wound);
}
PatternClass::Transfer => {
let at = along(DRAG_FROM, DRAG_TO, blood.dragged);
gizmos.line(DRAG_FROM, DRAG_TO, rig.with_alpha(0.3));
gizmos.lineloop(
[
at + Vec3::new(-0.22, 0.01, -0.12),
at + Vec3::new(0.22, 0.01, -0.12),
at + Vec3::new(0.22, 0.01, 0.12),
at + Vec3::new(-0.22, 0.01, 0.12),
],
rig,
);
}
}
}
fn hud(blood: Res<Blood>, paint: Res<Paint>, mut readout: Query<&mut Text, With<Readout>>) {
let s = &blood.settings;
let live = paint.stains.len();
let text = match blood.active {
None => "press 1-6 to fire a class; C clears the scene\n\
each key is a different mechanism, not one cone at six volumes"
.to_string(),
Some(PatternClass::Impact) => format!(
"impact spatter: the percolation cone\n\
{} droplets thrown, {} landed in view, {live} stains live\n\
droplet size is inversely correlated with speed, which is the model, not a look",
blood.emitted, blood.landed
),
Some(PatternClass::ArterialSpurt) => {
let age = blood.bleed.age(blood.tick);
let pressure =
(1.0 - age as f32 / s.pressure_decay_ticks.max(1) as f32).clamp(0.0, 1.0);
format!(
"arterial: ONE arc per systole, at {:.0} bpm\n\
age {age} ticks pressure {:.0} % systoles {} last arc {} droplets \
{live} stains live\n\
reach falls to zero over pressure_decay_ticks = {}; the arc thins as the body empties",
s.spurt_bpm,
pressure * 100.0,
blood.systoles,
blood.emitted,
s.pressure_decay_ticks
)
}
Some(PatternClass::CastOff) => format!(
"cast-off: tangential to the tip's path (Williams 10.1111/1556-4029.13855)\n\
load {:.2} ml of {:.2} swing {} last shed {} droplets {live} stains live\n\
pendant volume capped at {:.2} ml (Adam 2019); the slow return is below \
CAST_OFF_MIN_V and sheds nothing",
blood.load, blood.load_start, blood.swings, blood.emitted, s.cast_off_max_ml
),
Some(PatternClass::Expirated) => format!(
"expirated: a fine mist, and a bubble-ring count that is usually zero\n\
{} droplets, {} landed in view bubble rings {} {live} stains live\n\
rings occur in only {:.0} % of patterns and only above {:.0} mm \
(Donaldson 10.1007/s00414-010-0498-5)",
blood.emitted,
blood.landed,
blood.rings,
s.expirated_ring_fraction * 100.0,
s.expirated_ring_min_mm
),
Some(PatternClass::DripTrail) => {
let speed = WALK_SPEEDS.get(blood.walk).copied().unwrap_or(1.0);
format!(
"drip trail: spacing encodes speed. press 5 again to walk faster\n\
speed {speed:.1} m/s spacing {:.2} m load {:.2} ml of {:.2} \
{} drips {live} stains live\n\
spacing is drip_spacing_ref {:.2} m per m/s; each drip costs blood, so the trail \
ends rather than continuing",
s.drip_spacing_ref * speed,
blood.load,
blood.load_start,
blood.landed,
s.drip_spacing_ref
)
}
Some(PatternClass::Transfer) => format!(
"transfer: a dragged body runs out of blood\n\
load {:.2} ml of {:.2} contacts {} dragged {:.2} m of {:.2} {live} stains live\n\
moved = min(load, transfer_rate {:.2} ml) per contact, so the smear holds its width \
while blood remains, thins on the last contact, then the drag leaves nothing",
blood.load,
blood.load_start,
blood.contacts,
blood.dragged,
(DRAG_TO - DRAG_FROM).length(),
s.transfer_rate
),
};
for mut line in &mut readout {
line.0 = text.clone();
}
}
fn wound_at(at: Vec3, normal: Vec3) -> blood::Wound {
blood::Wound {
at: arr(at),
normal: arr(normal.normalize_or_zero()),
area: WOUND_AREA,
severity: 1.0,
kind: WoundKind::Severance,
}
}
fn arr(v: Vec3) -> [f32; 3] {
[v.x, v.y, v.z]
}
fn along(from: Vec3, to: Vec3, distance: f32) -> Vec3 {
from + (to - from).normalize_or_zero() * distance
}
fn swing_offset(phase: u32) -> Vec3 {
let stroke = SWING_TICKS.max(1);
let t = if phase < stroke {
phase as f32 / stroke as f32
} else {
let rest = SWING_PERIOD.max(stroke + 1) - stroke;
1.0 - (phase - stroke) as f32 / rest as f32
};
let angle = (SWING_FROM_DEG + (SWING_TO_DEG - SWING_FROM_DEG) * t.clamp(0.0, 1.0)).to_radians();
Vec3::new(0.0, angle.cos(), angle.sin()) * ARM
}
fn paint_drops(
commands: &mut Commands,
paint: &mut Paint,
s: &BloodSettings,
from: Vec3,
drops: &[Droplet],
) -> usize {
let mut landed = 0;
for (index, drop) in drops.iter().enumerate() {
let Some(hit) = first_hit(from, drop, s, drop_seed(from, index)) else {
continue;
};
spawn_stain(commands, paint, &hit);
landed += 1;
}
landed
}
fn drop_seed(from: Vec3, index: usize) -> u32 {
let q = |x: f32| (x / blood::WELD).round() as i64 as u32;
q(from.x)
^ q(from.y).wrapping_mul(0x9E37_79B9)
^ q(from.z).wrapping_mul(2_654_435_761)
^ (index as u32).wrapping_mul(0x85EB_CA6B)
}
fn first_hit(from: Vec3, drop: &Droplet, s: &BloodSettings, seed: u32) -> Option<Hit> {
let velocity = Vec3::new(drop.dir[0], drop.dir[1], drop.dir[2]) * drop.speed;
if let Some((at, arrival)) = wall_crossing(from, velocity, s.gravity) {
let through = arrival.z.abs();
let in_plane = Vec2::new(arrival.x, arrival.y);
let impact = Impact {
speed: arrival.length(),
diameter: drop.diameter,
angle_rad: if in_plane.length() > 0.0 {
through.atan2(in_plane.length())
} else {
FRAC_PI_2
},
roughness: s.substrate_roughness,
travel: in_plane.normalize_or_zero().to_array(),
};
let shape = stain_shape(&impact, s, seed);
let radius = stain_radius(drop, impact.speed, s);
let aspect = if shape.major > 0.0 { shape.minor / shape.major } else { 1.0 };
let facing = shape.direction;
return Some(Hit {
at,
normal: Vec3::Z,
rotation: Quat::from_rotation_z(facing[1].atan2(facing[0])),
long: radius * 2.0,
short: radius * 2.0 * aspect,
});
}
let landed = blood::landing(arr(from), drop, s.gravity, 0.0)?;
let at = Vec3::new(landed[0], 0.0, landed[2]);
if at.x.abs() > FLOOR_HALF || at.z.abs() > FLOOR_HALF {
return None;
}
let impact = impact_at_plane(drop, arr(from), 0.0, s);
let shape = stain_shape(&impact, s, seed);
let radius = stain_radius(drop, impact.speed, s);
let aspect = if shape.major > 0.0 { shape.minor / shape.major } else { 1.0 };
let facing = shape.direction;
Some(Hit {
at,
normal: Vec3::Y,
rotation: Quat::from_rotation_y((-facing[1]).atan2(facing[0]))
* Quat::from_rotation_x(-FRAC_PI_2),
long: radius * 2.0,
short: radius * 2.0 * aspect,
})
}
fn wall_crossing(from: Vec3, velocity: Vec3, gravity: f32) -> Option<(Vec3, Vec3)> {
if velocity.z >= 0.0 || from.z <= WALL_Z {
return None;
}
let t = (WALL_Z - from.z) / velocity.z;
if !(t > 0.0) || !t.is_finite() {
return None;
}
let y = from.y + velocity.y * t - 0.5 * gravity * t * t;
let x = from.x + velocity.x * t;
if y < 0.0 || y > WALL_TOP_Y || x.abs() > WALL_HALF_X {
return None;
}
Some((Vec3::new(x, y, WALL_Z), Vec3::new(velocity.x, velocity.y - gravity * t, velocity.z)))
}
fn paint_flat(commands: &mut Commands, paint: &mut Paint, at: Vec3, radius: f32) {
let hit = Hit {
at,
normal: Vec3::Y,
rotation: Quat::from_rotation_x(-FRAC_PI_2),
long: radius * 2.0,
short: radius * 2.0,
};
spawn_stain(commands, paint, &hit);
}
fn spawn_stain(commands: &mut Commands, paint: &mut Paint, hit: &Hit) {
let lift = 0.002 + (paint.spawned % 32) as f32 * 0.0003;
let entity = commands
.spawn((
Mesh3d(paint.disc.clone()),
MeshMaterial3d(paint.blood.clone()),
Transform {
translation: hit.at + hit.normal * lift,
rotation: hit.rotation,
scale: Vec3::new(hit.long.max(1.0e-4), hit.short.max(1.0e-4), 1.0),
},
))
.id();
paint.spawned = paint.spawned.wrapping_add(1);
paint.stains.push_back(entity);
while paint.stains.len() > MAX_STAINS {
let Some(oldest) = paint.stains.pop_front() else {
break;
};
commands.entity(oldest).despawn();
}
}
fn cross(gizmos: &mut Gizmos, at: Vec3, radius: f32, color: Color) {
gizmos.line(at - Vec3::X * radius, at + Vec3::X * radius, color);
gizmos.line(at - Vec3::Y * radius, at + Vec3::Y * radius, color);
gizmos.line(at - Vec3::Z * radius, at + Vec3::Z * radius, color);
}